feat(auth): migrate frontend from username to email-only authentication

- Login page: email input replaces username field (type=email, name=email)
- Login server action: reads email, uses i18n error for missing credentials
- AccountSection: email input (type=email) replaces username text field
- New user server action: sends email as required field, drops username
- UsersListPanel: displays and searches by email instead of username
- Admin edit user page: heading and delete confirm use email
- Profile page: fullName fallback uses email, drops @username display
- app.d.ts: email required on User, username removed
- Generated API types: AppUser.email required, username removed; CreateUserRequest.email required, username removed
- i18n: login_label_email, login_error_missing_credentials, admin_col_login updated (de/en/es)
- errors.ts: MISSING_CREDENTIALS → login_error_missing_credentials

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-18 21:34:46 +02:00
committed by marcel
parent 5e01db1c74
commit d816e94a90
19 changed files with 64 additions and 55 deletions

View File

@@ -56,8 +56,9 @@
"label_optional": "Optional", "label_optional": "Optional",
"label_required_fields": "Pflichtfelder", "label_required_fields": "Pflichtfelder",
"login_heading": "Anmelden", "login_heading": "Anmelden",
"login_label_username": "Benutzername", "login_label_email": "E-Mail-Adresse",
"login_label_password": "Passwort", "login_label_password": "Passwort",
"login_error_missing_credentials": "Bitte E-Mail-Adresse und Passwort eingeben.",
"login_btn_submit": "Anmelden", "login_btn_submit": "Anmelden",
"docs_search_placeholder": "Titel, Personen, Tags durchsuchen…", "docs_search_placeholder": "Titel, Personen, Tags durchsuchen…",
"docs_sort_label": "Sortierung", "docs_sort_label": "Sortierung",
@@ -167,7 +168,7 @@
"admin_tab_groups": "Gruppen", "admin_tab_groups": "Gruppen",
"admin_tab_tags": "Schlagworte", "admin_tab_tags": "Schlagworte",
"admin_section_users": "Benutzerverwaltung", "admin_section_users": "Benutzerverwaltung",
"admin_col_login": "Login", "admin_col_login": "E-Mail",
"admin_col_groups": "Gruppen", "admin_col_groups": "Gruppen",
"admin_col_password": "Passwort", "admin_col_password": "Passwort",
"admin_multiselect_hint": "Strg+Klick für Auswahl", "admin_multiselect_hint": "Strg+Klick für Auswahl",

View File

@@ -56,8 +56,9 @@
"label_optional": "Optional", "label_optional": "Optional",
"label_required_fields": "Required fields", "label_required_fields": "Required fields",
"login_heading": "Sign in", "login_heading": "Sign in",
"login_label_username": "Username", "login_label_email": "Email",
"login_label_password": "Password", "login_label_password": "Password",
"login_error_missing_credentials": "Please enter your email address and password.",
"login_btn_submit": "Sign in", "login_btn_submit": "Sign in",
"docs_search_placeholder": "Search title, people, tags…", "docs_search_placeholder": "Search title, people, tags…",
"docs_sort_label": "Sort", "docs_sort_label": "Sort",
@@ -167,7 +168,7 @@
"admin_tab_groups": "Groups", "admin_tab_groups": "Groups",
"admin_tab_tags": "Tags", "admin_tab_tags": "Tags",
"admin_section_users": "User management", "admin_section_users": "User management",
"admin_col_login": "Login", "admin_col_login": "Email",
"admin_col_groups": "Groups", "admin_col_groups": "Groups",
"admin_col_password": "Password", "admin_col_password": "Password",
"admin_multiselect_hint": "Ctrl+Click to select", "admin_multiselect_hint": "Ctrl+Click to select",

View File

@@ -56,8 +56,9 @@
"label_optional": "Opcional", "label_optional": "Opcional",
"label_required_fields": "Campos obligatorios", "label_required_fields": "Campos obligatorios",
"login_heading": "Iniciar sesión", "login_heading": "Iniciar sesión",
"login_label_username": "Usuario", "login_label_email": "Correo electrónico",
"login_label_password": "Contraseña", "login_label_password": "Contraseña",
"login_error_missing_credentials": "Por favor, introduzca su correo electrónico y contraseña.",
"login_btn_submit": "Iniciar sesión", "login_btn_submit": "Iniciar sesión",
"docs_search_placeholder": "Buscar título, personas, etiquetas…", "docs_search_placeholder": "Buscar título, personas, etiquetas…",
"docs_sort_label": "Ordenar", "docs_sort_label": "Ordenar",
@@ -167,7 +168,7 @@
"admin_tab_groups": "Grupos", "admin_tab_groups": "Grupos",
"admin_tab_tags": "Etiquetas", "admin_tab_tags": "Etiquetas",
"admin_section_users": "Gestión de usuarios", "admin_section_users": "Gestión de usuarios",
"admin_col_login": "Login", "admin_col_login": "Correo electrónico",
"admin_col_groups": "Grupos", "admin_col_groups": "Grupos",
"admin_col_password": "Contraseña", "admin_col_password": "Contraseña",
"admin_multiselect_hint": "Ctrl+Clic para seleccionar", "admin_multiselect_hint": "Ctrl+Clic para seleccionar",

View File

@@ -5,11 +5,10 @@ declare global {
// Define the User structure matching your Java Entity // Define the User structure matching your Java Entity
interface User { interface User {
id: string; id: string;
username: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
birthDate?: string; birthDate?: string;
email?: string; email: string;
contact?: string; contact?: string;
groups: { groups: {
id: string; id: string;

View File

@@ -33,6 +33,7 @@ export type ErrorCode =
| 'TAG_NOT_FOUND' | 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF' | 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET' | 'TAG_MERGE_INVALID_TARGET'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED' | 'UNAUTHORIZED'
| 'FORBIDDEN' | 'FORBIDDEN'
| 'VALIDATION_ERROR' | 'VALIDATION_ERROR'
@@ -118,6 +119,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_tag_merge_self(); return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET': case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target(); return m.error_tag_merge_invalid_target();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED': case 'UNAUTHORIZED':
return m.error_unauthorized(); return m.error_unauthorized();
case 'FORBIDDEN': case 'FORBIDDEN':

View File

@@ -1253,13 +1253,12 @@ export interface components {
AppUser: { AppUser: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
username: string;
password?: string; password?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: date */ /** Format: date */
birthDate?: string; birthDate?: string;
email?: string; email: string;
contact?: string; contact?: string;
enabled: boolean; enabled: boolean;
notifyOnReply: boolean; notifyOnReply: boolean;
@@ -1406,8 +1405,7 @@ export interface components {
blockIds?: string[]; blockIds?: string[];
}; };
CreateUserRequest: { CreateUserRequest: {
username?: string; email: string;
email?: string;
initialPassword?: string; initialPassword?: string;
groupIds?: string[]; groupIds?: string[];
firstName?: string; firstName?: string;

View File

@@ -10,7 +10,7 @@ type Group = {
type User = { type User = {
id: string; id: string;
username: string; email: string;
firstName: string | null; firstName: string | null;
lastName: string | null; lastName: string | null;
groups: Group[]; groups: Group[];
@@ -41,7 +41,7 @@ const filtered = $derived(
searchQuery.trim() === '' searchQuery.trim() === ''
? users ? users
: users.filter((u) => : users.filter((u) =>
[u.username, u.firstName, u.lastName] [u.email, u.firstName, u.lastName]
.filter(Boolean) .filter(Boolean)
.some((v) => v!.toLowerCase().includes(searchQuery.toLowerCase())) .some((v) => v!.toLowerCase().includes(searchQuery.toLowerCase()))
) )
@@ -128,7 +128,7 @@ const filtered = $derived(
? 'border-primary bg-primary/10 dark:bg-primary/15' ? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}" : 'border-transparent hover:bg-muted'}"
> >
<div class="text-sm font-bold text-ink">{user.username}</div> <div class="text-sm font-bold text-ink">{user.email}</div>
{#if fullName} {#if fullName}
<div class="mt-0.5 text-xs text-ink-3">{fullName}</div> <div class="mt-0.5 text-xs text-ink-3">{fullName}</div>
{/if} {/if}

View File

@@ -19,7 +19,7 @@ let deleteFormEl = $state<HTMLFormElement | null>(null);
async function handleDelete() { async function handleDelete() {
const confirmed = await confirm({ const confirmed = await confirm({
title: m.admin_user_delete_confirm({ username: data.editUser.username }), title: m.admin_user_delete_confirm({ username: data.editUser.email }),
destructive: true destructive: true
}); });
if (confirmed) deleteFormEl!.requestSubmit(); if (confirmed) deleteFormEl!.requestSubmit();
@@ -49,7 +49,7 @@ $effect(() => {
</svg> </svg>
</a> </a>
<h2 class="flex-1 font-sans text-sm font-bold text-ink"> <h2 class="flex-1 font-sans text-sm font-bold text-ink">
{m.admin_user_edit_heading({ username: data.editUser.username })} {m.admin_user_edit_heading({ username: data.editUser.email })}
</h2> </h2>
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance> <form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance>
<button <button

View File

@@ -16,7 +16,6 @@ const groups = [
const makeUser = (overrides = {}) => ({ const makeUser = (overrides = {}) => ({
id: 'u1', id: 'u1',
username: 'max',
firstName: 'Max', firstName: 'Max',
lastName: 'Mustermann', lastName: 'Mustermann',
email: 'max@example.com', email: 'max@example.com',
@@ -52,9 +51,11 @@ afterEach(cleanup);
// ─── Rendering ──────────────────────────────────────────────────────────────── // ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page rendering', () => { describe('Admin edit user page rendering', () => {
it('renders the heading with username', async () => { it('renders the heading with email', async () => {
renderPage({ data: baseData, form: null }); renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument(); await expect
.element(page.getByText(/Benutzer bearbeiten: max@example.com/i))
.toBeInTheDocument();
}); });
it('pre-fills first name from editUser data', async () => { it('pre-fills first name from editUser data', async () => {

View File

@@ -16,12 +16,12 @@ beforeEach(() => vi.clearAllMocks());
describe('admin/users layout load', () => { describe('admin/users layout load', () => {
it('returns the users list', async () => { it('returns the users list', async () => {
mockApi([ mockApi([
{ id: 'u1', username: 'alice' }, { id: 'u1', email: 'alice@example.com' },
{ id: 'u2', username: 'bob' } { id: 'u2', email: 'bob@example.com' }
]); ]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch }); const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.users).toHaveLength(2); expect(result.users).toHaveLength(2);
expect(result.users[0].username).toBe('alice'); expect(result.users[0].email).toBe('alice@example.com');
}); });
it('returns an empty array when the API returns nothing', async () => { it('returns an empty array when the API returns nothing', async () => {

View File

@@ -12,14 +12,14 @@ afterEach(cleanup);
const users = [ const users = [
{ {
id: 'u1', id: 'u1',
username: 'reader', email: 'reader@example.com',
firstName: 'Lea', firstName: 'Lea',
lastName: 'Leserin', lastName: 'Leserin',
groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }] groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }]
}, },
{ {
id: 'u2', id: 'u2',
username: 'admin', email: 'admin@example.com',
firstName: null, firstName: null,
lastName: null, lastName: null,
groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }] groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }]
@@ -46,10 +46,10 @@ describe('UsersListPanel — header', () => {
}); });
describe('UsersListPanel — user items', () => { describe('UsersListPanel — user items', () => {
it('renders each username', async () => { it('renders each email', async () => {
render(UsersListPanel, { users }); render(UsersListPanel, { users });
await expect.element(page.getByRole('link', { name: /reader/i })).toBeInTheDocument(); await expect.element(page.getByText('reader@example.com')).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /admin/i })).toBeInTheDocument(); await expect.element(page.getByText('admin@example.com')).toBeInTheDocument();
}); });
it('each user links to /admin/users/[id]', async () => { it('each user links to /admin/users/[id]', async () => {

View File

@@ -24,9 +24,8 @@ export const actions: Actions = {
const birthDateRaw = data.get('birthDate') as string; const birthDateRaw = data.get('birthDate') as string;
const result = await api.POST('/api/users', { const result = await api.POST('/api/users', {
body: { body: {
username: data.get('username') as string, email: data.get('email') as string,
initialPassword: data.get('password') as string, initialPassword: data.get('password') as string,
email: (data.get('email') as string) || undefined,
groupIds: data.getAll('groupIds') as string[], groupIds: data.getAll('groupIds') as string[],
firstName: (data.get('firstName') as string) || null, firstName: (data.get('firstName') as string) || null,
lastName: (data.get('lastName') as string) || null, lastName: (data.get('lastName') as string) || null,

View File

@@ -11,9 +11,10 @@ import { m } from '$lib/paraglide/messages.js';
{m.admin_col_login()} {m.admin_col_login()}
</span> </span>
<input <input
type="text" type="email"
name="username" name="email"
required required
autocomplete="email"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/> />
</label> </label>

View File

@@ -22,9 +22,10 @@ describe('Admin new user page rendering', () => {
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument();
}); });
it('renders the login input', async () => { it('renders the email input', async () => {
render(Page, { data: baseData, form: null }); render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument(); const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input).not.toBeNull();
}); });
it('renders group checkboxes for each available group', async () => { it('renders group checkboxes for each available group', async () => {

View File

@@ -16,9 +16,9 @@ const tick = () => new Promise((r) => setTimeout(r, 0));
const makeData = (overrides = {}) => ({ const makeData = (overrides = {}) => ({
user: { user: {
id: '1', id: '1',
username: 'max',
firstName: 'Max', firstName: 'Max',
lastName: 'Müller', lastName: 'Müller',
email: 'max@example.com',
groups: [], groups: [],
enabled: true, enabled: true,
createdAt: '' createdAt: ''
@@ -39,7 +39,7 @@ describe('Layout user avatar button', () => {
it('shows fallback icon button when names are not set', async () => { it('shows fallback icon button when names are not set', async () => {
render(Layout, { render(Layout, {
data: makeData({ data: makeData({
user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' } user: { id: '1', email: 'fallback@example.com', groups: [], enabled: true, createdAt: '' }
}), }),
children: emptySnippet children: emptySnippet
}); });

View File

@@ -5,14 +5,14 @@ import { getErrorMessage } from '$lib/errors';
export const actions = { export const actions = {
login: async ({ request, cookies, fetch }) => { login: async ({ request, cookies, fetch }) => {
const data = await request.formData(); const data = await request.formData();
const username = data.get('username') as string; const email = data.get('email') as string;
const password = data.get('password') as string; const password = data.get('password') as string;
if (!username || !password) { if (!email || !password) {
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' }); return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
} }
const credentials = btoa(`${username}:${password}`); const credentials = btoa(`${email}:${password}`);
const authHeader = `Basic ${credentials}`; const authHeader = `Basic ${credentials}`;
// Raw fetch is intentional here: we need to pass an explicit Authorization // Raw fetch is intentional here: we need to pass an explicit Authorization

View File

@@ -32,16 +32,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<form method="POST" action="?/login" class="space-y-5"> <form method="POST" action="?/login" class="space-y-5">
<div> <div>
<label <label
for="username" for="email"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase" class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.login_label_username()}</label >{m.login_label_email()}</label
> >
<input <input
type="text" type="email"
name="username" name="email"
id="username" id="email"
required required
autocomplete="username" autocomplete="email"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/> />
</div> </div>

View File

@@ -21,10 +21,10 @@ describe('Login page rendering', () => {
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
}); });
it('renders the username input', async () => { it('renders the email input', async () => {
render(LoginPage, {}); render(LoginPage, {});
await tick(); await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]'); const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input).not.toBeNull(); expect(input).not.toBeNull();
}); });
@@ -35,10 +35,10 @@ describe('Login page rendering', () => {
expect(input).not.toBeNull(); expect(input).not.toBeNull();
}); });
it('username field is required', async () => { it('email field is required', async () => {
render(LoginPage, {}); render(LoginPage, {});
await tick(); await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]'); const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.required).toBe(true); expect(input?.required).toBe(true);
}); });
@@ -49,6 +49,13 @@ describe('Login page rendering', () => {
expect(input?.required).toBe(true); expect(input?.required).toBe(true);
}); });
it('email field has type="email"', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.type).toBe('email');
});
it('password field has type="password"', async () => { it('password field has type="password"', async () => {
render(LoginPage, {}); render(LoginPage, {});
await tick(); await tick();

View File

@@ -6,7 +6,7 @@ let { data } = $props();
const fullName = $derived.by(() => { const fullName = $derived.by(() => {
const first = data.profileUser.firstName; const first = data.profileUser.firstName;
const last = data.profileUser.lastName; const last = data.profileUser.lastName;
return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.username; return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.email;
}); });
const initials = $derived.by(() => { const initials = $derived.by(() => {
@@ -70,12 +70,9 @@ const initials = $derived.by(() => {
{/if} {/if}
</div> </div>
<!-- Name and username --> <!-- Name -->
<div class="mb-5 text-center"> <div class="mb-5 text-center">
<h2 class="font-serif text-xl font-bold text-ink">{fullName}</h2> <h2 class="font-serif text-xl font-bold text-ink">{fullName}</h2>
{#if data.profileUser.firstName || data.profileUser.lastName}
<p class="mt-0.5 font-sans text-sm text-ink-3">@{data.profileUser.username}</p>
{/if}
</div> </div>
<!-- Field rows --> <!-- Field rows -->