feat(profile): set a display name and greet with it on the dashboard

Settings: new Profil card with display name and Steam ID. Registration: optional display name. The dashboard falls back to the email prefix when no display name is set.
This commit is contained in:
lobo committed 2026-09-24 10:45:56 +02:00
1 parent 6773040d59
commit 7b676f0a1e
7 files changed
+90 -13

No files matched your search

+12 -3
View File
@@ -29,7 +29,9 @@
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
"ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft." "ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.",
"DISPLAY_NAME": "Anzeigename (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben."
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Einstellungen", "TITLE": "Einstellungen",
@@ -50,7 +52,13 @@
"STEAM_ID": "Steam-ID", "STEAM_ID": "Steam-ID",
"SAVE_STEAM_ID": "Steam-ID speichern", "SAVE_STEAM_ID": "Steam-ID speichern",
"SAVED": "Einstellungen gespeichert.", "SAVED": "Einstellungen gespeichert.",
"ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden." "ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.",
"PROFILE": "Profil",
"DISPLAY_NAME": "Anzeigename",
"DISPLAY_NAME_PLACEHOLDER": "z. B. Lobo",
"DISPLAY_NAME_HINT": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . -",
"SAVE_PROFILE": "Profil speichern",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -61,7 +69,8 @@
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.", "STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.",
"STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.", "STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.",
"LANGUAGE_INVALID": "Nicht unterstützte Sprache." "LANGUAGE_INVALID": "Nicht unterstützte Sprache.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
"INFO": { "INFO": {
"ERROR": { "ERROR": {
+12 -3
View File
@@ -29,7 +29,9 @@
"ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_GENERIC": "Registration failed. Please try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_EMAIL_IN_USE": "This email address is already registered.",
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
"ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account." "ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.",
"DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken."
}, },
"SETTINGS": { "SETTINGS": {
"TITLE": "Settings", "TITLE": "Settings",
@@ -50,7 +52,13 @@
"STEAM_ID": "Steam ID", "STEAM_ID": "Steam ID",
"SAVE_STEAM_ID": "Save Steam ID", "SAVE_STEAM_ID": "Save Steam ID",
"SAVED": "Settings saved.", "SAVED": "Settings saved.",
"ERROR_SAVE": "Could not save settings." "ERROR_SAVE": "Could not save settings.",
"PROFILE": "Profile",
"DISPLAY_NAME": "Display name",
"DISPLAY_NAME_PLACEHOLDER": "e.g. Lobo",
"DISPLAY_NAME_HINT": "3–24 characters: letters, digits, spaces, _ . -",
"SAVE_PROFILE": "Save profile",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -61,7 +69,8 @@
"INVITE_CODE_REQUIRED": "Invite code is required.", "INVITE_CODE_REQUIRED": "Invite code is required.",
"STEAM_ID_REQUIRED": "Steam ID is required.", "STEAM_ID_REQUIRED": "Steam ID is required.",
"STEAM_ID_INVALID": "Steam ID must be a positive number.", "STEAM_ID_INVALID": "Steam ID must be a positive number.",
"LANGUAGE_INVALID": "Unsupported language." "LANGUAGE_INVALID": "Unsupported language.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
"INFO": { "INFO": {
"ERROR": { "ERROR": {
+1 -1
View File
@@ -127,7 +127,7 @@ const Dashboard = () => {
<div className="relative min-w-0"> <div className="relative min-w-0">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p> <p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate"> <h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋 {t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
</h1> </h1>
{hasSteamId ? ( {hasSteamId ? (
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground"> <Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
+13
View File
@@ -17,6 +17,7 @@ const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
}; };
export default function RegistrationPage() { export default function RegistrationPage() {
@@ -36,6 +37,7 @@ export default function RegistrationPage() {
passwordConfirmation: '', passwordConfirmation: '',
inviteCode: '', inviteCode: '',
steamId: '', steamId: '',
displayName: '',
}, },
}); });
@@ -51,6 +53,7 @@ export default function RegistrationPage() {
password: values.password, password: values.password,
inviteCode: values.inviteCode, inviteCode: values.inviteCode,
steamId: values.steamId, steamId: values.steamId,
displayName: values.displayName,
}), }),
}); });
@@ -122,6 +125,16 @@ export default function RegistrationPage() {
/> />
</FormField> </FormField>
<FormField id="displayName" label={t('REGISTRATION.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input
id="displayName"
type="text"
maxLength={24}
aria-invalid={!!errors.displayName}
{...register('displayName')}
/>
</FormField>
<FormField id="steamId" label={t('REGISTRATION.STEAM_ID')} error={errors.steamId} t={t}> <FormField id="steamId" label={t('REGISTRATION.STEAM_ID')} error={errors.steamId} t={t}>
<Input <Input
id="steamId" id="steamId"
+22 -6
View File
@@ -20,6 +20,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
}; };
const SUPPORTED_LANGUAGES = ['en', 'de']; const SUPPORTED_LANGUAGES = ['en', 'de'];
@@ -49,7 +50,7 @@ export default function SettingsPage() {
formState: {errors, isSubmitting}, formState: {errors, isSubmitting},
} = useForm({ } = useForm({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''}, defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
}); });
useEffect(() => { useEffect(() => {
@@ -86,6 +87,7 @@ export default function SettingsPage() {
useDarkTheme: !!data.settings?.useDarkTheme, useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en', language: data.settings?.language || 'en',
steamId: data.steamId ?? '', steamId: data.steamId ?? '',
displayName: data.displayName ?? '',
}); });
} catch (err) { } catch (err) {
console.error('Settings load error:', err); console.error('Settings load error:', err);
@@ -146,6 +148,7 @@ export default function SettingsPage() {
useDarkTheme: next, useDarkTheme: next,
language: i18n.language, language: i18n.language,
steamId: profile?.steamId ?? null, steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
}); });
}; };
@@ -155,17 +158,19 @@ export default function SettingsPage() {
useDarkTheme: isDarkMode, useDarkTheme: isDarkMode,
language: next, language: next,
steamId: profile?.steamId ?? null, steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null,
}); });
}; };
const onSubmitSteamId = async (values) => { const onSubmitProfile = async (values) => {
const result = await persistSettings({ const result = await persistSettings({
useDarkTheme: isDarkMode, useDarkTheme: isDarkMode,
language: i18n.language, language: i18n.language,
steamId: values.steamId, steamId: values.steamId,
displayName: values.displayName,
}); });
if (result.ok) { if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId} : p)); setProfile((p) => (p ? {...p, steamId: values.steamId, displayName: values.displayName} : p));
} }
}; };
@@ -240,10 +245,21 @@ export default function SettingsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.STEAM')}</CardTitle> <CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate> <form onSubmit={handleSubmit(onSubmitProfile)} className="space-y-4" noValidate>
<FormField id="displayName" label={t('SETTINGS.DISPLAY_NAME')} error={errors.displayName} t={t}>
<Input
id="displayName"
type="text"
maxLength={24}
placeholder={t('SETTINGS.DISPLAY_NAME_PLACEHOLDER')}
aria-invalid={!!errors.displayName}
{...register('displayName')}
/>
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
</FormField>
<FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}> <FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}>
<Input <Input
id="steamId" id="steamId"
@@ -256,7 +272,7 @@ export default function SettingsPage() {
</FormField> </FormField>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>} {isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
{t('SETTINGS.SAVE_STEAM_ID')} {t('SETTINGS.SAVE_PROFILE')}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
+13
View File
@@ -22,6 +22,17 @@ const steamIdField = z
.min(1, 'VALIDATION.STEAM_ID_REQUIRED') .min(1, 'VALIDATION.STEAM_ID_REQUIRED')
.regex(/^\d{17}$/, 'VALIDATION.STEAM_ID_INVALID'); .regex(/^\d{17}$/, 'VALIDATION.STEAM_ID_INVALID');
// Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends.
// An empty field means "no display name" and is sent as null.
const displayNameField = z
.string()
.trim()
.optional()
.transform((v) => (v ? v : null))
.refine((v) => v === null || /^[\p{L}\p{N}_.-][\p{L}\p{N} _.-]{1,22}[\p{L}\p{N}_.-]$/u.test(v), {
message: 'VALIDATION.DISPLAY_NAME_INVALID',
});
export const registrationSchema = z export const registrationSchema = z
.object({ .object({
email: emailField, email: emailField,
@@ -29,6 +40,7 @@ export const registrationSchema = z
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField, inviteCode: inviteCodeField,
steamId: steamIdField, steamId: steamIdField,
displayName: displayNameField,
}) })
.refine((d) => d.password === d.passwordConfirmation, { .refine((d) => d.password === d.passwordConfirmation, {
path: ['passwordConfirmation'], path: ['passwordConfirmation'],
@@ -39,4 +51,5 @@ export const settingsSchema = z.object({
useDarkTheme: z.boolean(), useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}), language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
steamId: steamIdField, steamId: steamIdField,
displayName: displayNameField,
}); });
+17
View File
@@ -28,3 +28,20 @@ describe('steamId validation', () => {
expect(result.steamId).toBe('76561198005679788'); expect(result.steamId).toBe('76561198005679788');
}); });
}); });
describe('displayName validation', () => {
const withName = (displayName) => ({...settings('76561198005679788'), displayName});
it('turns an empty field into null (no display name)', () => {
expect(settingsSchema.parse(withName(' ')).displayName).toBeNull();
});
it('trims and keeps a valid name', () => {
expect(settingsSchema.parse(withName(' Lobo Corp_1 ')).displayName).toBe('Lobo Corp_1');
});
it.each(['ab', '<script>', 'a'.repeat(25)])('rejects %j', (displayName) => {
expect(settingsSchema.safeParse(withName(displayName)).success).toBe(false);
});
});