Skip to content

Commit 6f588c5

Browse files
authored
Merge pull request #8 from helpwave/issue/3-account-theme
Implement simple account theme
2 parents 278cb94 + e6a19c0 commit 6f588c5

16 files changed

Lines changed: 661 additions & 12 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
- uses: actions/checkout@v4
1515
- uses: actions/setup-node@v4
1616
- uses: bahmutov/npm-install@v1
17+
- run: npm run check-translations
1718
- run: npm run build-keycloak-theme
1819

1920
check_if_version_upgraded:

locales/de-DE.arb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,38 @@
112112
"@doLogout": {
113113
"description": "Abmelden Button Text"
114114
},
115+
"doSave": "Speichern",
116+
"@doSave": {
117+
"description": "Speichern Button Text"
118+
},
119+
"updatePassword": "Passwort ändern",
120+
"@updatePassword": {
121+
"description": "Passwort ändern Button Text"
122+
},
123+
"accountStatusActive": "Aktiv",
124+
"@accountStatusActive": {
125+
"description": "Kontostatus wenn angemeldet"
126+
},
127+
"profilePicture": "Profilbild",
128+
"@profilePicture": {
129+
"description": "Profilbild Abschnittsüberschrift"
130+
},
131+
"profilePictureComingSoon": "Profilbild hochladen – in Kürze.",
132+
"@profilePictureComingSoon": {
133+
"description": "Platzhalter für Profilbild-Upload"
134+
},
135+
"accountSectionProfile": "Profil",
136+
"@accountSectionProfile": {
137+
"description": "Profil-Abschnittsüberschrift"
138+
},
139+
"personalInfoTitle": "Persönliche Angaben",
140+
"@personalInfoTitle": {
141+
"description": "Persönliche Angaben Abschnittsüberschrift"
142+
},
143+
"passwordSectionTitle": "Passwort",
144+
"@passwordSectionTitle": {
145+
"description": "Passwort-Abschnittsüberschrift"
146+
},
115147
"doCancel": "Abbrechen",
116148
"@doCancel": {
117149
"description": "Abbrechen Button Text"

locales/en-US.arb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,38 @@
112112
"@doLogout": {
113113
"description": "Logout button text"
114114
},
115+
"doSave": "Save",
116+
"@doSave": {
117+
"description": "Save button text"
118+
},
119+
"updatePassword": "Update Password",
120+
"@updatePassword": {
121+
"description": "Update password button text"
122+
},
123+
"accountStatusActive": "Active",
124+
"@accountStatusActive": {
125+
"description": "Account status label when signed in"
126+
},
127+
"profilePicture": "Profile picture",
128+
"@profilePicture": {
129+
"description": "Profile picture section heading"
130+
},
131+
"profilePictureComingSoon": "Upload profile picture – coming soon.",
132+
"@profilePictureComingSoon": {
133+
"description": "Placeholder for profile picture upload"
134+
},
135+
"accountSectionProfile": "Profile",
136+
"@accountSectionProfile": {
137+
"description": "Account profile section title"
138+
},
139+
"personalInfoTitle": "Personal information",
140+
"@personalInfoTitle": {
141+
"description": "Personal info section title"
142+
},
143+
"passwordSectionTitle": "Password",
144+
"@passwordSectionTitle": {
145+
"description": "Password section title"
146+
},
115147
"doCancel": "Cancel",
116148
"@doCancel": {
117149
"description": "Cancel button text"

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "id.helpwave.de",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"repository": {
55
"type": "git",
66
"url": "git://github.com/helpwave/id.helpwave.de.git"
@@ -11,6 +11,7 @@
1111
"dev": "vite",
1212
"storybook": "storybook dev -p 6006",
1313
"build-intl": "npx --package=@helpwave/internationalization build-intl --force -i ./locales -o ./src/i18n/translations.ts -n helpwaveIdTranslation",
14+
"check-translations": "node scripts/check-translation-keys.mjs",
1415
"build": "npm run build-intl && tsc && vite build",
1516
"build-keycloak-theme": "npm run build && keycloakify build",
1617
"build-storybook": "storybook build",

scripts/check-translation-keys.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
6+
const projectRoot = path.resolve(__dirname, '..')
7+
const localesDir = path.join(projectRoot, 'locales')
8+
const srcDir = path.join(projectRoot, 'src')
9+
10+
const T_CALL_RE = /\bt\s*\(\s*['"]([^'"]+)['"]/g
11+
12+
function loadArbKeys(filePath) {
13+
const raw = fs.readFileSync(filePath, 'utf8')
14+
const json = JSON.parse(raw)
15+
return new Set(
16+
Object.keys(json).filter(
17+
(k) => !k.startsWith('@') && k !== '@@locale'
18+
)
19+
)
20+
}
21+
22+
function allArbFiles() {
23+
const names = fs.readdirSync(localesDir)
24+
return names
25+
.filter((n) => n.endsWith('.arb') && !n.includes('/'))
26+
.map((n) => path.join(localesDir, n))
27+
}
28+
29+
function collectKeysFromSource(dir, keys = new Set()) {
30+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
31+
return keys
32+
}
33+
const entries = fs.readdirSync(dir, { withFileTypes: true })
34+
for (const e of entries) {
35+
const full = path.join(dir, e.name)
36+
if (e.name === 'node_modules' || e.name === 'translations.ts') continue
37+
if (e.isDirectory()) {
38+
collectKeysFromSource(full, keys)
39+
continue
40+
}
41+
if (!/\.(tsx?|jsx?|mjs|cjs)$/.test(e.name)) continue
42+
const content = fs.readFileSync(full, 'utf8')
43+
let m
44+
T_CALL_RE.lastIndex = 0
45+
while ((m = T_CALL_RE.exec(content)) !== null) {
46+
keys.add(m[1])
47+
}
48+
}
49+
return keys
50+
}
51+
52+
function main() {
53+
const usedKeys = collectKeysFromSource(srcDir)
54+
55+
const arbPaths = allArbFiles()
56+
if (arbPaths.length === 0) {
57+
console.error('No ARB files found in', localesDir)
58+
process.exit(1)
59+
}
60+
61+
const localeKeys = new Map()
62+
for (const p of arbPaths) {
63+
const locale = path.basename(p, '.arb')
64+
localeKeys.set(locale, loadArbKeys(p))
65+
}
66+
67+
const allLocales = [...localeKeys.keys()]
68+
const missing = []
69+
for (const key of usedKeys) {
70+
for (const locale of allLocales) {
71+
if (!localeKeys.get(locale).has(key)) {
72+
missing.push({ key, locale })
73+
}
74+
}
75+
}
76+
77+
if (missing.length > 0) {
78+
console.error('Missing translation keys (used in code but not in ARB):\n')
79+
const byKey = new Map()
80+
for (const { key, locale } of missing) {
81+
if (!byKey.has(key)) byKey.set(key, [])
82+
byKey.get(key).push(locale)
83+
}
84+
for (const [key, locales] of byKey) {
85+
console.error(` [${key}] missing in: ${locales.join(', ')}`)
86+
}
87+
process.exit(1)
88+
}
89+
90+
const arbKeySet = localeKeys.get(allLocales[0])
91+
const unused = [...arbKeySet].filter((k) => !usedKeys.has(k))
92+
if (unused.length > 0) {
93+
console.warn('Unused keys in ARB (present in locale but not found in src):')
94+
for (const k of unused.sort()) {
95+
console.warn(` ${k}`)
96+
}
97+
}
98+
99+
console.log('All translation keys used in code exist in every locale.')
100+
}
101+
102+
main()

src/account/KcContext.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* eslint-disable @typescript-eslint/no-empty-object-type */
2+
import type { ExtendKcContext } from 'keycloakify/account'
3+
import type { KcEnvName, ThemeName } from '../kc.gen'
4+
5+
export type KcContextExtension = {
6+
themeName: ThemeName,
7+
properties: Record<KcEnvName, string> & {},
8+
}
9+
10+
export type KcContextExtensionPerPage = {}
11+
12+
export type KcContext = ExtendKcContext<KcContextExtension, KcContextExtensionPerPage>

src/account/KcPage.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Suspense } from 'react'
2+
import type { KcContext } from './KcContext'
3+
import { AccountPageLayout } from './components/AccountPageLayout'
4+
import AccountSettings from './pages/AccountSettings'
5+
6+
export default function KcPage(props: { kcContext: KcContext }) {
7+
const { kcContext } = props
8+
9+
return (
10+
<Suspense>
11+
{(() => {
12+
if (kcContext.pageId === 'account.ftl') {
13+
return (
14+
<AccountPageLayout kcContext={kcContext}>
15+
<AccountSettings
16+
kcContext={kcContext}
17+
/>
18+
</AccountPageLayout>
19+
)
20+
}
21+
22+
return (
23+
<AccountPageLayout kcContext={kcContext}>
24+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
25+
<p>
26+
{kcContext.pageId === 'password.ftl'
27+
? 'Use the link below to update your password.'
28+
: 'Use the link below to return to your account.'}
29+
</p>
30+
{kcContext.pageId === 'password.ftl' && (
31+
<a
32+
href={kcContext.url.passwordUrl}
33+
className="text-[var(--hw-color-primary-600)] underline"
34+
>
35+
Update Password
36+
</a>
37+
)}
38+
<a
39+
href={kcContext.url.accountUrl}
40+
className="text-[var(--hw-color-primary-600)] underline"
41+
>
42+
Back to Account
43+
</a>
44+
</div>
45+
</AccountPageLayout>
46+
)
47+
})()}
48+
</Suspense>
49+
)
50+
}

src/account/KcPageStory.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { DeepPartial } from 'keycloakify/tools/DeepPartial'
2+
import type { KcContext } from './KcContext'
3+
import { createGetKcContextMock } from 'keycloakify/account/KcContext'
4+
import type { KcContextExtension, KcContextExtensionPerPage } from './KcContext'
5+
import KcPage from './KcPage'
6+
import { themeNames, kcEnvDefaults } from '../kc.gen'
7+
8+
const kcContextExtension: KcContextExtension = {
9+
themeName: themeNames[0],
10+
properties: {
11+
...kcEnvDefaults
12+
}
13+
}
14+
const kcContextExtensionPerPage: KcContextExtensionPerPage = {}
15+
16+
export const { getKcContextMock } = createGetKcContextMock({
17+
kcContextExtension,
18+
kcContextExtensionPerPage,
19+
overrides: {},
20+
overridesPerPage: {}
21+
})
22+
23+
export function createKcPageStory<PageId extends KcContext['pageId']>(params: { pageId: PageId }) {
24+
const { pageId } = params
25+
26+
function KcPageStory(props: {
27+
kcContext?: DeepPartial<Extract<KcContext, { pageId: PageId }>>,
28+
}) {
29+
const { kcContext: overrides } = props
30+
31+
const kcContextMock = getKcContextMock({
32+
pageId,
33+
overrides
34+
})
35+
36+
return <KcPage kcContext={kcContextMock} />
37+
}
38+
39+
return { KcPageStory }
40+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { ReactNode } from 'react'
2+
import type { KcContext } from '../KcContext'
3+
import { Branding } from '../../login/components/Branding'
4+
import { ThemeSwitcher } from '../../login/components/ThemeSwitcher'
5+
import { LanguageSwitcher } from '../../login/components/LanguageSwitcher'
6+
import { Footer } from '../../login/components/Footer'
7+
import { hideKeycloakStyles } from '../../login/utils/hideKeycloakStyles'
8+
import { Button } from '@helpwave/hightide'
9+
import { useTranslation } from '../../i18n/useTranslation'
10+
11+
type AccountPageLayoutProps = {
12+
kcContext: KcContext,
13+
children: ReactNode,
14+
}
15+
16+
export function AccountPageLayout({ kcContext, children }: AccountPageLayoutProps) {
17+
const t = useTranslation()
18+
19+
return (
20+
<>
21+
<style>{hideKeycloakStyles}</style>
22+
<div className="flex flex-col min-h-screen p-4 relative">
23+
<div className="absolute top-4 right-4 flex gap-2 z-[1000] sm:top-2 sm:right-2 sm:gap-1 items-center">
24+
<ThemeSwitcher />
25+
<LanguageSwitcher />
26+
<Button
27+
type="button"
28+
color="negative"
29+
onClick={() => {
30+
window.location.href = kcContext.url.getLogoutUrl()
31+
}}
32+
>
33+
{t('doLogout')}
34+
</Button>
35+
</div>
36+
37+
<div className="flex flex-col items-center justify-center flex-1 w-[360px] max-w-[360px] mx-auto py-8 px-4 md:w-full md:max-w-[360px] md:py-6 md:px-4 sm:w-full sm:max-w-full sm:py-4 sm:px-2">
38+
<Branding />
39+
40+
<div className="w-full max-w-full mt-8 box-border [&_form]:w-full [&_form]:max-w-full [&_form]:box-border [&_>*]:w-full [&_>*]:max-w-full [&_>*]:box-border [&_input]:w-full [&_input]:max-w-full [&_input]:box-border [&_button]:w-full [&_button]:max-w-full [&_button]:box-border">
41+
{children}
42+
</div>
43+
</div>
44+
45+
<Footer />
46+
</div>
47+
</>
48+
)
49+
}

src/account/i18n.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/* eslint-disable @typescript-eslint/no-unused-vars */
2+
import { i18nBuilder } from 'keycloakify/account'
3+
import type { ThemeName } from '../kc.gen'
4+
5+
const { useI18n, ofTypeI18n } = i18nBuilder.withThemeName<ThemeName>().build()
6+
7+
type I18n = typeof ofTypeI18n
8+
9+
export { useI18n, type I18n }

0 commit comments

Comments
 (0)