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:
@@ -10,7 +10,7 @@ type Group = {
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
groups: Group[];
|
||||
@@ -41,7 +41,7 @@ const filtered = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? users
|
||||
: users.filter((u) =>
|
||||
[u.username, u.firstName, u.lastName]
|
||||
[u.email, u.firstName, u.lastName]
|
||||
.filter(Boolean)
|
||||
.some((v) => v!.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
@@ -128,7 +128,7 @@ const filtered = $derived(
|
||||
? 'border-primary bg-primary/10 dark:bg-primary/15'
|
||||
: '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}
|
||||
<div class="mt-0.5 text-xs text-ink-3">{fullName}</div>
|
||||
{/if}
|
||||
|
||||
@@ -19,7 +19,7 @@ let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
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
|
||||
});
|
||||
if (confirmed) deleteFormEl!.requestSubmit();
|
||||
@@ -49,7 +49,7 @@ $effect(() => {
|
||||
</svg>
|
||||
</a>
|
||||
<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>
|
||||
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance>
|
||||
<button
|
||||
|
||||
@@ -16,7 +16,6 @@ const groups = [
|
||||
|
||||
const makeUser = (overrides = {}) => ({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
email: 'max@example.com',
|
||||
@@ -52,9 +51,11 @@ afterEach(cleanup);
|
||||
// ─── 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 });
|
||||
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 () => {
|
||||
|
||||
@@ -16,12 +16,12 @@ beforeEach(() => vi.clearAllMocks());
|
||||
describe('admin/users layout load', () => {
|
||||
it('returns the users list', async () => {
|
||||
mockApi([
|
||||
{ id: 'u1', username: 'alice' },
|
||||
{ id: 'u2', username: 'bob' }
|
||||
{ id: 'u1', email: 'alice@example.com' },
|
||||
{ id: 'u2', email: 'bob@example.com' }
|
||||
]);
|
||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
||||
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 () => {
|
||||
|
||||
@@ -12,14 +12,14 @@ afterEach(cleanup);
|
||||
const users = [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'reader',
|
||||
email: 'reader@example.com',
|
||||
firstName: 'Lea',
|
||||
lastName: 'Leserin',
|
||||
groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }]
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }]
|
||||
@@ -46,10 +46,10 @@ describe('UsersListPanel — header', () => {
|
||||
});
|
||||
|
||||
describe('UsersListPanel — user items', () => {
|
||||
it('renders each username', async () => {
|
||||
it('renders each email', async () => {
|
||||
render(UsersListPanel, { users });
|
||||
await expect.element(page.getByRole('link', { name: /reader/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('link', { name: /admin/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByText('reader@example.com')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('admin@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('each user links to /admin/users/[id]', async () => {
|
||||
|
||||
@@ -24,9 +24,8 @@ export const actions: Actions = {
|
||||
const birthDateRaw = data.get('birthDate') as string;
|
||||
const result = await api.POST('/api/users', {
|
||||
body: {
|
||||
username: data.get('username') as string,
|
||||
email: data.get('email') as string,
|
||||
initialPassword: data.get('password') as string,
|
||||
email: (data.get('email') as string) || undefined,
|
||||
groupIds: data.getAll('groupIds') as string[],
|
||||
firstName: (data.get('firstName') as string) || null,
|
||||
lastName: (data.get('lastName') as string) || null,
|
||||
|
||||
@@ -11,9 +11,10 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
{m.admin_col_login()}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
type="email"
|
||||
name="email"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -22,9 +22,10 @@ describe('Admin new user page – rendering', () => {
|
||||
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 });
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user