Public Access
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:
1 parent
6773040d59
commit
7b676f0a1e
7 files changed
+90
-13
No files matched your search
+12
-3
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in new issue
Block a user