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_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
|
||||
"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": {
|
||||
"TITLE": "Einstellungen",
|
||||
@@ -50,7 +52,13 @@
|
||||
"STEAM_ID": "Steam-ID",
|
||||
"SAVE_STEAM_ID": "Steam-ID speichern",
|
||||
"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": {
|
||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||
@@ -61,7 +69,8 @@
|
||||
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
|
||||
"STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.",
|
||||
"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": {
|
||||
"ERROR": {
|
||||
|
||||
+12
-3
@@ -29,7 +29,9 @@
|
||||
"ERROR_GENERIC": "Registration failed. Please try again.",
|
||||
"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_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": {
|
||||
"TITLE": "Settings",
|
||||
@@ -50,7 +52,13 @@
|
||||
"STEAM_ID": "Steam ID",
|
||||
"SAVE_STEAM_ID": "Save Steam ID",
|
||||
"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": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -61,7 +69,8 @@
|
||||
"INVITE_CODE_REQUIRED": "Invite code is required.",
|
||||
"STEAM_ID_REQUIRED": "Steam ID is required.",
|
||||
"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": {
|
||||
"ERROR": {
|
||||
|
||||
@@ -127,7 +127,7 @@ const Dashboard = () => {
|
||||
<div className="relative min-w-0">
|
||||
<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">
|
||||
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
|
||||
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
|
||||
</h1>
|
||||
{hasSteamId ? (
|
||||
<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',
|
||||
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
|
||||
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() {
|
||||
@@ -36,6 +37,7 @@ export default function RegistrationPage() {
|
||||
passwordConfirmation: '',
|
||||
inviteCode: '',
|
||||
steamId: '',
|
||||
displayName: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,6 +53,7 @@ export default function RegistrationPage() {
|
||||
password: values.password,
|
||||
inviteCode: values.inviteCode,
|
||||
steamId: values.steamId,
|
||||
displayName: values.displayName,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -122,6 +125,16 @@ export default function RegistrationPage() {
|
||||
/>
|
||||
</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}>
|
||||
<Input
|
||||
id="steamId"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
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'];
|
||||
@@ -49,7 +50,7 @@ export default function SettingsPage() {
|
||||
formState: {errors, isSubmitting},
|
||||
} = useForm({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''},
|
||||
defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,6 +87,7 @@ export default function SettingsPage() {
|
||||
useDarkTheme: !!data.settings?.useDarkTheme,
|
||||
language: data.settings?.language || 'en',
|
||||
steamId: data.steamId ?? '',
|
||||
displayName: data.displayName ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Settings load error:', err);
|
||||
@@ -146,6 +148,7 @@ export default function SettingsPage() {
|
||||
useDarkTheme: next,
|
||||
language: i18n.language,
|
||||
steamId: profile?.steamId ?? null,
|
||||
displayName: profile?.displayName ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -155,17 +158,19 @@ export default function SettingsPage() {
|
||||
useDarkTheme: isDarkMode,
|
||||
language: next,
|
||||
steamId: profile?.steamId ?? null,
|
||||
displayName: profile?.displayName ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitSteamId = async (values) => {
|
||||
const onSubmitProfile = async (values) => {
|
||||
const result = await persistSettings({
|
||||
useDarkTheme: isDarkMode,
|
||||
language: i18n.language,
|
||||
steamId: values.steamId,
|
||||
displayName: values.displayName,
|
||||
});
|
||||
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>
|
||||
<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>
|
||||
<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}>
|
||||
<Input
|
||||
id="steamId"
|
||||
@@ -256,7 +272,7 @@ export default function SettingsPage() {
|
||||
</FormField>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
|
||||
{t('SETTINGS.SAVE_STEAM_ID')}
|
||||
{t('SETTINGS.SAVE_PROFILE')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -22,6 +22,17 @@ const steamIdField = z
|
||||
.min(1, 'VALIDATION.STEAM_ID_REQUIRED')
|
||||
.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
|
||||
.object({
|
||||
email: emailField,
|
||||
@@ -29,6 +40,7 @@ export const registrationSchema = z
|
||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||
inviteCode: inviteCodeField,
|
||||
steamId: steamIdField,
|
||||
displayName: displayNameField,
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirmation, {
|
||||
path: ['passwordConfirmation'],
|
||||
@@ -39,4 +51,5 @@ export const settingsSchema = z.object({
|
||||
useDarkTheme: z.boolean(),
|
||||
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
|
||||
steamId: steamIdField,
|
||||
displayName: displayNameField,
|
||||
});
|
||||
@@ -28,3 +28,20 @@ describe('steamId validation', () => {
|
||||
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