feat(account): Login, Registrierung, Passwort zurücksetzen
- appwrite 22.4.1 (exakt gepinnt); Client mit Endpoint aus VITE_APPWRITE_ENDPOINT bzw. location.origin + /v1, Projekt aus VITE_APPWRITE_PROJECT_ID bzw. aircard - auth.ts: getCurrentUser (401 -> null, ungültige Antwort -> Fehler), login, register, logout, Passwort-Link anfordern/abschließen, onAuthChange, validatePassword - errors.ts: deutsche Meldungen nach type, dann code (Tabelle aus dem Plan) - Login-Dialog (Anmelden/Registrieren/Passwort vergessen) als gemeinsames <dialog>, Konto-Menü im Seitenkopf, Seite /passwort-neu - Tests: errors, auth, Dialog, Passwort-neu-Seite Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1d4d621a7
commit
10c55ca933
15 files changed
+1866
-10
No files matched your search
@@ -0,0 +1,64 @@
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { currentUser, logout, onAuthChange } from './auth';
|
||||
import { errorMessage } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
export interface AccountMenu {
|
||||
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
||||
ready(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
let isReady = false;
|
||||
|
||||
function render(): void {
|
||||
if (!isReady) {
|
||||
slot.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const user = currentUser();
|
||||
if (!user) {
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
|
||||
signIn.addEventListener('click', () => void requireLogin('login'));
|
||||
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
||||
return;
|
||||
}
|
||||
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
||||
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
|
||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||
signOut.addEventListener('click', () => {
|
||||
signOut.disabled = true;
|
||||
error.textContent = '';
|
||||
// Bei Erfolg zeichnet onAuthChange das Menü neu; nur der Fehlerfall braucht Handarbeit.
|
||||
logout().catch((err: unknown) => {
|
||||
error.textContent = errorMessage(err, 'load');
|
||||
signOut.disabled = false;
|
||||
});
|
||||
});
|
||||
slot.replaceChildren(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
|
||||
el('div', { class: 'account-row' }, email, signOut),
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const off = onAuthChange(render);
|
||||
render();
|
||||
|
||||
return {
|
||||
ready() {
|
||||
isReady = true;
|
||||
render();
|
||||
},
|
||||
dispose() {
|
||||
off();
|
||||
slot.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* Konto: Login-Dialog, Konto-Menü im Kopf, Seite „Passwort neu setzen“. */
|
||||
|
||||
/* Knöpfe (eigene Klassen, damit das Konto-Modul nicht am Editor-Stylesheet hängt) */
|
||||
|
||||
.auth-btn {
|
||||
appearance: none;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.auth-btn:hover:not(:disabled) {
|
||||
border-color: var(--sel-line);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.auth-btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-btn-primary:hover:not(:disabled) {
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.auth-btn-small {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--sel-line);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-link:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Konto-Menü im Seitenkopf */
|
||||
|
||||
#account-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
min-width: 0;
|
||||
max-width: 28ch;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-error {
|
||||
color: #ff9a9d;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Login-Dialog */
|
||||
|
||||
.auth-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
.auth-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.auth-close {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-close:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
margin: 0 40px 16px 0;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-tab[aria-selected='true'] {
|
||||
background: var(--sel-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-tab:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Formulare (Dialog und Passwort-neu-Seite) */
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
|
||||
}
|
||||
|
||||
.auth-input:focus-visible {
|
||||
outline: 2px solid var(--sel-line);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.auth-input[aria-invalid='true'] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.auth-input[readonly] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: -6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-msg {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-msg:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-msg-error {
|
||||
color: #ff9a9d;
|
||||
}
|
||||
|
||||
.auth-msg-ok {
|
||||
color: #8fd9a8;
|
||||
}
|
||||
|
||||
.auth-aside {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Seite /passwort-neu */
|
||||
|
||||
.reset-page {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.reset-page .auth-msg {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reset-actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
|
||||
|
||||
@media (max-width: 599px) {
|
||||
#app-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
#account-slot {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Appwrite-Client der Seite: ein Client, die Dienste daraus.
|
||||
// Im Browser läuft die API über dieselbe Domain (`/v1`), damit das Session-Cookie first-party ist.
|
||||
import { Account, Client, Storage, TablesDB } from 'appwrite';
|
||||
|
||||
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
|
||||
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || 'aircard';
|
||||
|
||||
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
|
||||
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
|
||||
|
||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||
export const account: Account = new Account(client);
|
||||
export const tables: TablesDB = new TablesDB(client);
|
||||
export const storage: Storage = new Storage(client);
|
||||
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createEmailPasswordSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({ account: mocks }));
|
||||
|
||||
type Auth = typeof import('./auth');
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
|
||||
// Der Nutzer-Cache lebt im Modul; jeder Test bekommt eine frische Instanz.
|
||||
async function freshAuth(): Promise<Auth> {
|
||||
vi.resetModules();
|
||||
return import('./auth');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('liefert den Nutzer und merkt sich die ID', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
it('401 heißt: nicht angemeldet', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
|
||||
await expect(auth.getCurrentUser()).resolves.toBeNull();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('currentUserId ist vor dem ersten Aufruf null', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('eine Antwort ohne $id/email (z. B. HTML) ist ein Fehler und ändert den Cache nicht', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockResolvedValueOnce({ message: '<!doctype html>' });
|
||||
await expect(auth.getCurrentUser()).rejects.toThrow('Unerwartete Antwort');
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
it('andere Fehler werden weitergereicht', async () => {
|
||||
const auth = await freshAuth();
|
||||
const error = new TypeError('Failed to fetch');
|
||||
mocks.get.mockRejectedValue(error);
|
||||
await expect(auth.getCurrentUser()).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('benachrichtigt nur, wenn sich der Nutzer ändert', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
});
|
||||
|
||||
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
const error = fail(401, 'user_invalid_credentials');
|
||||
mocks.createEmailPasswordSession.mockRejectedValue(error);
|
||||
await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(error);
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
||||
const params = mocks.create.mock.calls[0][0];
|
||||
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
|
||||
expect(typeof params.userId).toBe('string');
|
||||
expect(params.userId.length).toBeGreaterThan(0);
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'neu@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u2');
|
||||
});
|
||||
|
||||
it('kürzt den Namen auf 128 Zeichen', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'x@b.de' });
|
||||
await auth.register(`${'a'.repeat(200)}@b.de`, 'geheim123');
|
||||
expect(mocks.create.mock.calls[0][0].name).toBe('a'.repeat(128));
|
||||
});
|
||||
|
||||
it('meldet nicht an, wenn das Konto nicht angelegt werden konnte', async () => {
|
||||
const auth = await freshAuth();
|
||||
const error = fail(409, 'user_already_exists');
|
||||
mocks.create.mockRejectedValue(error);
|
||||
await expect(auth.register('a@b.de', 'geheim123')).rejects.toBe(error);
|
||||
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('löscht die aktuelle Sitzung, leert den Cache und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.login('a@b.de', 'geheim123');
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
expect(listener).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('401 (Sitzung war schon weg) löst auf und ruft onAuthChange(null)', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await expect(auth.logout()).resolves.toBeUndefined();
|
||||
expect(listener).toHaveBeenCalledWith(null);
|
||||
expect(auth.currentUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it('andere Fehler (z. B. Netz) fliegen weiter, der Cache bleibt', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.login('a@b.de', 'geheim123');
|
||||
mocks.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(auth.logout()).rejects.toBeInstanceOf(TypeError);
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAuthChange', () => {
|
||||
it('gibt eine Abmelde-Funktion zurück', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
const off = auth.onAuthChange(listener);
|
||||
off();
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Abonnent darf sich beim Benachrichtigen abmelden', async () => {
|
||||
const auth = await freshAuth();
|
||||
const second = vi.fn();
|
||||
const off = auth.onAuthChange(() => off());
|
||||
auth.onAuthChange(second);
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwort zurücksetzen', () => {
|
||||
it('requestPasswordReset sendet die Adresse und die Rücksprung-URL', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createRecovery.mockResolvedValue({});
|
||||
await auth.requestPasswordReset('a@b.de');
|
||||
expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` });
|
||||
});
|
||||
|
||||
it('completePasswordReset sendet userId, secret und neues Passwort', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.updateRecovery.mockResolvedValue({});
|
||||
await auth.completePasswordReset('u1', 'geheim-token', 'neuesPasswort1');
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 'geheim-token', password: 'neuesPasswort1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('lehnt zu kurze Passwörter mit einer Meldung ab', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('kurz')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(auth.validatePassword('')).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(auth.validatePassword('1234567')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('nimmt 8 bis 256 Zeichen an', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('12345678')).toBeNull();
|
||||
expect(auth.validatePassword('a'.repeat(256))).toBeNull();
|
||||
});
|
||||
|
||||
it('lehnt mehr als 256 Zeichen ab', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.validatePassword('a'.repeat(257))).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
|
||||
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
|
||||
import { ID } from 'appwrite';
|
||||
import { account } from './appwrite';
|
||||
import { isUnauthorized } from './errors';
|
||||
|
||||
export type User = { $id: string; email: string };
|
||||
|
||||
const PASSWORD_MIN = 8;
|
||||
const PASSWORD_MAX = 256;
|
||||
|
||||
let cachedUser: User | null = null;
|
||||
const listeners = new Set<(user: User | null) => void>();
|
||||
|
||||
function notify(): void {
|
||||
// Kopie, damit sich ein Abonnent beim Verteilen abmelden darf.
|
||||
for (const fn of [...listeners]) fn(cachedUser);
|
||||
}
|
||||
|
||||
function setUser(user: User | null): void {
|
||||
cachedUser = user;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Nur die Felder, die die Seite braucht; alles andere (z. B. eine HTML-Fehlerseite) ist ein Fehler. */
|
||||
function toUser(res: unknown): User {
|
||||
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
|
||||
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
|
||||
return { $id: data.$id, email: data.email };
|
||||
}
|
||||
|
||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
let next: User | null;
|
||||
try {
|
||||
next = toUser(await account.get());
|
||||
} catch (err) {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
next = null;
|
||||
}
|
||||
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Der zuletzt bekannte Nutzer, ohne Netzwerkzugriff. */
|
||||
export function currentUser(): User | null {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
/** Die ID des zuletzt bekannten Nutzers (aus getCurrentUser, login, register, logout). */
|
||||
export function currentUserId(): string | null {
|
||||
return cachedUser?.$id ?? null;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<User> {
|
||||
await account.createEmailPasswordSession({ email, password });
|
||||
const user = toUser(await account.get());
|
||||
setUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string): Promise<User> {
|
||||
await account.create({
|
||||
userId: ID.unique(),
|
||||
email,
|
||||
password,
|
||||
name: email.split('@')[0].slice(0, 128),
|
||||
});
|
||||
return login(email, password);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await account.deleteSession({ sessionId: 'current' });
|
||||
} catch (err) {
|
||||
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
await account.createRecovery({ email, url: `${location.origin}/passwort-neu` });
|
||||
}
|
||||
|
||||
export async function completePasswordReset(userId: string, secret: string, password: string): Promise<void> {
|
||||
await account.updateRecovery({ userId, secret, password });
|
||||
}
|
||||
|
||||
/** Registriert eine Funktion, die bei jeder Änderung des Nutzers aufgerufen wird; gibt die Abmelde-Funktion zurück. */
|
||||
export function onAuthChange(fn: (user: User | null) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
|
||||
export function validatePassword(pw: string): string | null {
|
||||
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
|
||||
? null
|
||||
: `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors';
|
||||
|
||||
/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */
|
||||
const fail = (code: number, type: string, message = '') => ({ code, type, message });
|
||||
|
||||
const LINK = 'Der Link ist ungültig oder abgelaufen.';
|
||||
const WRONG = 'E-Mail oder Passwort ist falsch.';
|
||||
const LENGTH = 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
|
||||
const COMMON = 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
|
||||
const EMAIL = 'Bitte gib eine gültige E-Mail-Adresse ein.';
|
||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
||||
|
||||
const PW_LENGTH = 'Invalid `password` param: Password must be between 8 and 256 characters long.';
|
||||
const PW_COMMON =
|
||||
'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.';
|
||||
const EMAIL_INVALID = 'Invalid `email` param: Value must be a valid email address';
|
||||
|
||||
describe('errorMessage nach type', () => {
|
||||
it('user_invalid_credentials', () => {
|
||||
expect(errorMessage(fail(401, 'user_invalid_credentials', 'Invalid credentials.'), 'login')).toBe(WRONG);
|
||||
});
|
||||
|
||||
it('user_already_exists', () => {
|
||||
expect(errorMessage(fail(409, 'user_already_exists'), 'register')).toBe(
|
||||
'Diese E-Mail ist schon registriert. Melde dich an.',
|
||||
);
|
||||
});
|
||||
|
||||
it('user_invalid_token', () => {
|
||||
expect(errorMessage(fail(401, 'user_invalid_token'), 'reset')).toBe(LINK);
|
||||
});
|
||||
|
||||
it('user_not_found hängt vom Kontext ab', () => {
|
||||
expect(errorMessage(fail(404, 'user_not_found'), 'recover')).toBe('Zu dieser E-Mail gibt es kein Konto.');
|
||||
expect(errorMessage(fail(404, 'user_not_found'), 'reset')).toBe(LINK);
|
||||
});
|
||||
|
||||
it('user_not_found in anderen Kontexten fällt auf die allgemeine Meldung zurück', () => {
|
||||
expect(errorMessage(fail(404, 'user_not_found'), 'save')).toBe(GENERIC);
|
||||
});
|
||||
|
||||
it('row_not_found', () => {
|
||||
expect(errorMessage(fail(404, 'row_not_found'), 'load')).toBe(
|
||||
'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.',
|
||||
);
|
||||
});
|
||||
|
||||
it('storage_file_not_found', () => {
|
||||
expect(errorMessage(fail(404, 'storage_file_not_found'), 'load')).toBe('Das Bild dieser Karte fehlt.');
|
||||
});
|
||||
|
||||
it('general_unknown_origin', () => {
|
||||
expect(errorMessage(fail(403, 'general_unknown_origin'), 'login')).toBe(
|
||||
'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.',
|
||||
);
|
||||
});
|
||||
|
||||
it('user_count_exceeded', () => {
|
||||
expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMessage bei general_argument_invalid', () => {
|
||||
const invalid = (message: string) => fail(400, 'general_argument_invalid', message);
|
||||
|
||||
it('email', () => {
|
||||
for (const ctx of ['login', 'register', 'recover'] as ErrorContext[]) {
|
||||
expect(errorMessage(invalid(EMAIL_INVALID), ctx)).toBe(EMAIL);
|
||||
}
|
||||
});
|
||||
|
||||
it('password beim Login heißt: falsche Zugangsdaten', () => {
|
||||
expect(errorMessage(invalid(PW_LENGTH), 'login')).toBe(WRONG);
|
||||
});
|
||||
|
||||
it('password sonst: 8 bis 256 Zeichen', () => {
|
||||
expect(errorMessage(invalid(PW_LENGTH), 'register')).toBe(LENGTH);
|
||||
expect(errorMessage(invalid(PW_LENGTH), 'reset')).toBe(LENGTH);
|
||||
});
|
||||
|
||||
it('password sonst: zu verbreitet', () => {
|
||||
expect(errorMessage(invalid(PW_COMMON), 'register')).toBe(COMMON);
|
||||
expect(errorMessage(invalid(PW_COMMON), 'reset')).toBe(COMMON);
|
||||
});
|
||||
|
||||
it('userId und secret: Link ungültig', () => {
|
||||
expect(errorMessage(invalid('Invalid `userId` param: Parameter must be a valid number'), 'reset')).toBe(LINK);
|
||||
expect(errorMessage(invalid('Invalid `secret` param: Value must be a valid string'), 'reset')).toBe(LINK);
|
||||
});
|
||||
|
||||
it('unbekannter oder fehlender Parameter: allgemeine Meldung', () => {
|
||||
expect(errorMessage(invalid('Invalid `url` param: Invalid URI'), 'recover')).toBe(GENERIC);
|
||||
expect(errorMessage(invalid('Something is wrong'), 'register')).toBe(GENERIC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMessage nach code und Fehlerart', () => {
|
||||
it('429', () => {
|
||||
expect(errorMessage(fail(429, 'general_rate_limit_exceeded'), 'login')).toBe('Zu viele Versuche – bitte kurz warten.');
|
||||
expect(errorMessage(fail(429, ''), 'recover')).toBe('Zu viele Versuche – bitte kurz warten.');
|
||||
});
|
||||
|
||||
it('type geht vor code', () => {
|
||||
expect(errorMessage(fail(429, 'user_invalid_credentials'), 'login')).toBe(WRONG);
|
||||
});
|
||||
|
||||
it('TypeError ist ein Netzfehler', () => {
|
||||
expect(errorMessage(new TypeError('Failed to fetch'), 'save')).toBe('Keine Verbindung zum Server.');
|
||||
});
|
||||
|
||||
it('alles andere ist die allgemeine Meldung', () => {
|
||||
expect(errorMessage(fail(500, 'general_server_error', 'boom'), 'save')).toBe(GENERIC);
|
||||
expect(errorMessage(new Error('x'), 'load')).toBe(GENERIC);
|
||||
expect(errorMessage(undefined, 'load')).toBe(GENERIC);
|
||||
expect(errorMessage('text', 'load')).toBe(GENERIC);
|
||||
expect(errorMessage(null, 'load')).toBe(GENERIC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnauthorized', () => {
|
||||
it('erkennt Code 401', () => {
|
||||
expect(isUnauthorized({ code: 401 })).toBe(true);
|
||||
});
|
||||
|
||||
it('andere Codes und Nicht-Fehler nicht', () => {
|
||||
expect(isUnauthorized({ code: 404 })).toBe(false);
|
||||
expect(isUnauthorized(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isUnauthorized(null)).toBe(false);
|
||||
expect(isUnauthorized(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorType', () => {
|
||||
it('liefert den type-String oder null', () => {
|
||||
expect(errorType(fail(409, 'user_already_exists'))).toBe('user_already_exists');
|
||||
expect(errorType(fail(500, ''))).toBeNull();
|
||||
expect(errorType(new TypeError('x'))).toBeNull();
|
||||
expect(errorType(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvalidLinkError (Passwort-neu-Seite)', () => {
|
||||
it('Link-Fehler', () => {
|
||||
expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true);
|
||||
expect(isInvalidLinkError(fail(404, 'user_not_found'))).toBe(true);
|
||||
expect(isInvalidLinkError(fail(400, 'general_argument_invalid', 'Invalid `userId` param: x'))).toBe(true);
|
||||
expect(isInvalidLinkError(fail(400, 'general_argument_invalid', 'Invalid `secret` param: x'))).toBe(true);
|
||||
});
|
||||
|
||||
it('Passwort- und sonstige Fehler gehören zum Feld', () => {
|
||||
expect(isInvalidLinkError(fail(400, 'general_argument_invalid', PW_LENGTH))).toBe(false);
|
||||
expect(isInvalidLinkError(fail(429, 'general_rate_limit_exceeded'))).toBe(false);
|
||||
expect(isInvalidLinkError(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isInvalidLinkError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
|
||||
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
|
||||
|
||||
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
|
||||
|
||||
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
|
||||
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
|
||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
||||
|
||||
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
|
||||
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
/** Der Fehlertyp des Servers (`type`), sonst null. */
|
||||
export function errorType(err: unknown): string | null {
|
||||
const type = field(err, 'type');
|
||||
return typeof type === 'string' && type !== '' ? type : null;
|
||||
}
|
||||
|
||||
/** Der Server hat die Sitzung abgelehnt (HTTP 401). */
|
||||
export function isUnauthorized(err: unknown): boolean {
|
||||
return field(err, 'code') === 401;
|
||||
}
|
||||
|
||||
/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */
|
||||
function invalidParam(err: unknown): string | null {
|
||||
const message = field(err, 'message');
|
||||
return typeof message === 'string' ? (/`(\w+)` param/.exec(message)?.[1] ?? null) : null;
|
||||
}
|
||||
|
||||
/** Passwort-neu-Seite: Fehler, die bedeuten, dass der Link aus der Mail nicht (mehr) taugt. */
|
||||
export function isInvalidLinkError(err: unknown): boolean {
|
||||
const type = errorType(err);
|
||||
if (type === 'user_invalid_token' || type === 'user_not_found') return true;
|
||||
if (type === 'general_argument_invalid') {
|
||||
const param = invalidParam(err);
|
||||
return param === 'userId' || param === 'secret';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function passwordMessage(err: unknown, ctx: ErrorContext): string {
|
||||
if (ctx === 'login') return WRONG_CREDENTIALS;
|
||||
const message = field(err, 'message');
|
||||
if (typeof message === 'string' && message.includes('commonly used')) {
|
||||
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
|
||||
}
|
||||
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
|
||||
export function errorMessage(err: unknown, ctx: ErrorContext): string {
|
||||
switch (errorType(err)) {
|
||||
case 'user_invalid_credentials':
|
||||
return WRONG_CREDENTIALS;
|
||||
case 'user_already_exists':
|
||||
return 'Diese E-Mail ist schon registriert. Melde dich an.';
|
||||
case 'user_invalid_token':
|
||||
return LINK_INVALID;
|
||||
case 'user_not_found':
|
||||
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
|
||||
if (ctx === 'reset') return LINK_INVALID;
|
||||
break;
|
||||
case 'general_argument_invalid': {
|
||||
const param = invalidParam(err);
|
||||
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
|
||||
if (param === 'password') return passwordMessage(err, ctx);
|
||||
if (param === 'userId' || param === 'secret') return LINK_INVALID;
|
||||
break;
|
||||
}
|
||||
case 'row_not_found':
|
||||
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
|
||||
case 'storage_file_not_found':
|
||||
return 'Das Bild dieser Karte fehlt.';
|
||||
case 'general_unknown_origin':
|
||||
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
|
||||
case 'user_count_exceeded':
|
||||
return 'Zurzeit sind keine neuen Konten möglich.';
|
||||
}
|
||||
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
|
||||
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
|
||||
return GENERIC;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createEmailPasswordSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({ account: mocks }));
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
|
||||
type Dialog = typeof import('./login-dialog');
|
||||
let requireLogin: Dialog['requireLogin'];
|
||||
let opener: HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.resetModules();
|
||||
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
|
||||
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||
opener.focus();
|
||||
({ requireLogin } = await import('./login-dialog'));
|
||||
});
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
const title = () => dialogEl().querySelector('h2')!.textContent;
|
||||
const input = (name: string) => dialogEl().querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const alertText = () => dialogEl().querySelector('[role="alert"]')!.textContent;
|
||||
const buttonByText = (text: string) =>
|
||||
[...dialogEl().querySelectorAll('button')].find((b) => b.textContent === text) as HTMLButtonElement;
|
||||
const submit = () =>
|
||||
dialogEl().querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const fill = (values: Record<string, string>) => {
|
||||
for (const [name, value] of Object.entries(values)) input(name).value = value;
|
||||
};
|
||||
|
||||
describe('requireLogin: Öffnen und Schließen', () => {
|
||||
it('öffnet den Dialog mit Anmelden und fokussiert die E-Mail', () => {
|
||||
void requireLogin();
|
||||
expect(dialogEl().open).toBe(true);
|
||||
expect(title()).toBe('Anmelden');
|
||||
expect(document.activeElement).toBe(input('email'));
|
||||
expect(input('email').autocomplete).toBe('email');
|
||||
expect(input('password').autocomplete).toBe('current-password');
|
||||
});
|
||||
|
||||
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
|
||||
void requireLogin('register');
|
||||
expect(title()).toBe('Registrieren');
|
||||
expect(input('password').autocomplete).toBe('new-password');
|
||||
expect(input('password-repeat').autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
it('der Schließen-Knopf löst mit false auf und gibt den Fokus zurück', async () => {
|
||||
const result = requireLogin();
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(dialogEl().open).toBe(false);
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis des Dialogs) löst mit false auf', async () => {
|
||||
const result = requireLogin();
|
||||
dialogEl().close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ein Klick auf den Hintergrund schließt, ein Klick im Formular nicht', async () => {
|
||||
const result = requireLogin();
|
||||
const email = input('email');
|
||||
email.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
email.click();
|
||||
expect(dialogEl().open).toBe(true);
|
||||
dialogEl().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl().click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(dialogEl().open).toBe(false);
|
||||
});
|
||||
|
||||
it('Markieren, das außerhalb endet (mousedown im Feld, click auf dem Dialog), schließt nicht', () => {
|
||||
void requireLogin();
|
||||
input('email').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl().click();
|
||||
expect(dialogEl().open).toBe(true);
|
||||
});
|
||||
|
||||
it('gleichzeitige Aufrufe teilen sich dasselbe Ergebnis und einen Dialog', () => {
|
||||
const first = requireLogin();
|
||||
const second = requireLogin('register');
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.auth-dialog')).toHaveLength(1);
|
||||
expect(title()).toBe('Anmelden');
|
||||
});
|
||||
|
||||
it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => {
|
||||
const first = requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
dialogEl().close();
|
||||
await first;
|
||||
const second = requireLogin();
|
||||
expect(second).not.toBe(first);
|
||||
expect(dialogEl().open).toBe(true);
|
||||
expect(input('email').value).toBe('');
|
||||
expect(input('password').value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anmelden', () => {
|
||||
it('meldet an, löst mit true auf und schließt', async () => {
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
const result = requireLogin();
|
||||
fill({ email: ' a@b.de ', password: 'geheim123' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||
expect(dialogEl().open).toBe(false);
|
||||
});
|
||||
|
||||
it('zeigt bei falschem Passwort die Meldung, bleibt offen und gibt die Knöpfe wieder frei', async () => {
|
||||
mocks.createEmailPasswordSession.mockRejectedValue(fail(401, 'user_invalid_credentials'));
|
||||
let settled = false;
|
||||
void requireLogin().then(() => (settled = true));
|
||||
fill({ email: 'a@b.de', password: 'falsch123' });
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('E-Mail oder Passwort ist falsch.'));
|
||||
expect(dialogEl().open).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
expect(buttonByText('Passwort vergessen?').disabled).toBe(false);
|
||||
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
expect(input('email').readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('sperrt Knöpfe und ignoriert ein zweites Absenden, solange die Anfrage läuft', async () => {
|
||||
let release!: () => void;
|
||||
mocks.createEmailPasswordSession.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
const result = requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
submit();
|
||||
submit();
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(1);
|
||||
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
expect(buttonByText('Registrieren').disabled).toBe(true);
|
||||
release();
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('Netzfehler: Meldung statt Absturz', async () => {
|
||||
mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
void requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
|
||||
});
|
||||
|
||||
it('eine Antwort nach dem Schließen ändert nichts mehr', async () => {
|
||||
let release!: () => void;
|
||||
mocks.createEmailPasswordSession.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
const result = requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
submit();
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(dialogEl().open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registrieren', () => {
|
||||
it('der Reiter wechselt die Ansicht und behält die E-Mail', () => {
|
||||
void requireLogin();
|
||||
fill({ email: 'a@b.de' });
|
||||
buttonByText('Registrieren').click();
|
||||
expect(title()).toBe('Registrieren');
|
||||
expect(input('email').value).toBe('a@b.de');
|
||||
expect(dialogEl().querySelector('#auth-tab-register')!.getAttribute('aria-selected')).toBe('true');
|
||||
expect(dialogEl().querySelector('#auth-tab-login')!.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
|
||||
void requireLogin('register');
|
||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
|
||||
submit();
|
||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||
fill({ password: 'kurz', 'password-repeat': 'kurz' });
|
||||
submit();
|
||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('legt das Konto an, meldet an und löst mit true auf', async () => {
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
const result = requireLogin('register');
|
||||
fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
expect(dialogEl().open).toBe(false);
|
||||
});
|
||||
|
||||
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
void requireLogin('register');
|
||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
|
||||
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
|
||||
expect(link.textContent).toBe('Anmelden');
|
||||
link.click();
|
||||
expect(title()).toBe('Anmelden');
|
||||
expect(input('email').value).toBe('a@b.de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwort vergessen', () => {
|
||||
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
|
||||
mocks.createRecovery.mockResolvedValue({});
|
||||
void requireLogin();
|
||||
fill({ email: 'a@b.de' });
|
||||
buttonByText('Passwort vergessen?').click();
|
||||
expect(title()).toBe('Passwort vergessen');
|
||||
expect(input('email').value).toBe('a@b.de');
|
||||
expect(dialogEl().querySelector('input[type="password"]')).toBeNull();
|
||||
submit();
|
||||
await vi.waitFor(() =>
|
||||
expect(dialogEl().querySelector('[role="status"]')!.textContent).toBe(
|
||||
'Wir haben dir einen Link geschickt. Er gilt eine Stunde.',
|
||||
),
|
||||
);
|
||||
expect(mocks.createRecovery).toHaveBeenCalledWith({ email: 'a@b.de', url: `${location.origin}/passwort-neu` });
|
||||
expect(dialogEl().open).toBe(true);
|
||||
});
|
||||
|
||||
it('zeigt „kein Konto“ als Fehler und führt zurück zur Anmeldung', async () => {
|
||||
mocks.createRecovery.mockRejectedValue(fail(404, 'user_not_found'));
|
||||
void requireLogin('forgot');
|
||||
fill({ email: 'x@b.de' });
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Zu dieser E-Mail gibt es kein Konto.'));
|
||||
buttonByText('Zurück zur Anmeldung').click();
|
||||
expect(title()).toBe('Anmelden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
||||
import { errorMessage, errorType, type ErrorContext } from './errors';
|
||||
|
||||
export type LoginView = 'login' | 'register' | 'forgot';
|
||||
|
||||
const TITLES: Record<LoginView, string> = {
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
forgot: 'Passwort vergessen',
|
||||
};
|
||||
|
||||
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
|
||||
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
|
||||
|
||||
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
|
||||
|
||||
/** Alles, was eine Ansicht dem Rahmen übergibt: Formular, erstes Feld, Meldungen. */
|
||||
type Panel = {
|
||||
form: HTMLFormElement;
|
||||
first: HTMLElement;
|
||||
errorEl: HTMLElement;
|
||||
okEl: HTMLElement;
|
||||
};
|
||||
|
||||
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
let view: LoginView = 'login';
|
||||
let draftEmail = '';
|
||||
let busy = false;
|
||||
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
|
||||
let session = 0;
|
||||
let opener: HTMLElement | null = null;
|
||||
let settle: ((ok: boolean) => void) | null = null;
|
||||
let pending: Promise<boolean> | null = null;
|
||||
|
||||
const closeButton = el(
|
||||
'button',
|
||||
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
|
||||
el('span', { 'aria-hidden': 'true' }, '×'),
|
||||
);
|
||||
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
|
||||
const content = el('div', { class: 'auth-content' });
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'auth-dialog', 'aria-labelledby': 'auth-title' },
|
||||
el('div', { class: 'auth-card' }, closeButton, title, content),
|
||||
);
|
||||
document.body.append(dialog);
|
||||
|
||||
/** Beendet den Dialog genau einmal; Esc, Schließen-Knopf und Hintergrund laufen hier zusammen. */
|
||||
function finish(ok: boolean): void {
|
||||
const done = settle;
|
||||
if (!done) return;
|
||||
settle = null;
|
||||
pending = null;
|
||||
busy = false;
|
||||
session += 1;
|
||||
content.replaceChildren(); // keine Eingaben (Passwörter) im geschlossenen Dialog zurücklassen
|
||||
if (dialog.open) dialog.close();
|
||||
restoreFocus();
|
||||
done(ok);
|
||||
}
|
||||
|
||||
function restoreFocus(): void {
|
||||
// Der „Anmelden“-Knopf im Konto-Menü wird nach dem Login ersetzt; dann geht der Fokus an dessen Nachfolger.
|
||||
const target = opener?.isConnected ? opener : document.querySelector<HTMLElement>('#account-slot button');
|
||||
opener = null;
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', () => finish(false));
|
||||
// Esc schließt nativ; `close` kommt asynchron, daher nicht eingreifen, wenn der Dialog schon wieder offen ist.
|
||||
dialog.addEventListener('close', () => {
|
||||
if (!dialog.open) finish(false);
|
||||
});
|
||||
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
|
||||
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||
let pressedOnBackdrop = false;
|
||||
dialog.addEventListener('mousedown', (event) => {
|
||||
pressedOnBackdrop = event.target === dialog;
|
||||
});
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog && pressedOnBackdrop) finish(false);
|
||||
pressedOnBackdrop = false;
|
||||
});
|
||||
|
||||
function field(
|
||||
labelText: string,
|
||||
name: string,
|
||||
type: 'email' | 'password',
|
||||
autocomplete: string,
|
||||
value = '',
|
||||
describedBy?: string,
|
||||
): Field {
|
||||
const input = el('input', {
|
||||
class: 'auth-input',
|
||||
type,
|
||||
name,
|
||||
autocomplete,
|
||||
value,
|
||||
required: true,
|
||||
autocapitalize: type === 'email' ? 'none' : null,
|
||||
spellcheck: type === 'email' ? 'false' : null,
|
||||
'aria-describedby': describedBy,
|
||||
});
|
||||
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
|
||||
return { label, input };
|
||||
}
|
||||
|
||||
function linkButton(text: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = el('button', { type: 'button', class: 'auth-link' }, text);
|
||||
button.addEventListener('click', () => {
|
||||
if (!busy) onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function buildTabs(): HTMLElement {
|
||||
const select = (next: LoginView) => {
|
||||
if (busy || next === view) return;
|
||||
showView(next, 'tab');
|
||||
};
|
||||
const tabs = (['login', 'register'] as const).map((tabView) => {
|
||||
const selected = view === tabView;
|
||||
const tab = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
id: `auth-tab-${tabView}`,
|
||||
class: 'auth-tab',
|
||||
'aria-selected': String(selected),
|
||||
'aria-controls': 'auth-panel',
|
||||
tabindex: selected ? 0 : -1,
|
||||
},
|
||||
TITLES[tabView],
|
||||
);
|
||||
tab.addEventListener('click', () => select(tabView));
|
||||
tab.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||
event.preventDefault();
|
||||
select(tabView === 'login' ? 'register' : 'login');
|
||||
});
|
||||
return tab;
|
||||
});
|
||||
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
|
||||
}
|
||||
|
||||
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
|
||||
function setBusy(panel: Panel, value: boolean): void {
|
||||
busy = value;
|
||||
panel.form.setAttribute('aria-busy', String(value));
|
||||
for (const input of panel.form.querySelectorAll('input')) input.readOnly = value;
|
||||
for (const button of content.querySelectorAll('button')) button.disabled = value;
|
||||
}
|
||||
|
||||
function showError(panel: Panel, text: string, extra?: Node): void {
|
||||
panel.okEl.textContent = '';
|
||||
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
|
||||
}
|
||||
|
||||
function showOk(panel: Panel, text: string): void {
|
||||
panel.errorEl.textContent = '';
|
||||
panel.okEl.textContent = text;
|
||||
}
|
||||
|
||||
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
|
||||
async function run(
|
||||
panel: Panel,
|
||||
ctx: ErrorContext,
|
||||
action: () => Promise<void>,
|
||||
onError?: (err: unknown) => Node | undefined,
|
||||
): Promise<void> {
|
||||
const mine = session;
|
||||
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setBusy(panel, true);
|
||||
panel.errorEl.textContent = '';
|
||||
panel.okEl.textContent = '';
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
if (mine !== session) return;
|
||||
showError(panel, errorMessage(err, ctx), onError?.(err));
|
||||
}
|
||||
if (mine !== session) return; // Dialog wurde inzwischen geschlossen (auch nach Erfolg)
|
||||
setBusy(panel, false);
|
||||
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
|
||||
}
|
||||
|
||||
function buildPanel(forView: LoginView): Panel {
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||||
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
||||
const form = el('form', { class: 'auth-form' });
|
||||
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
||||
|
||||
if (forView === 'login') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'current-password');
|
||||
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
|
||||
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const address = email.input.value.trim();
|
||||
void run(panel, 'login', async () => {
|
||||
await login(address, password.input.value);
|
||||
finish(true);
|
||||
});
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
if (forView === 'register') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
|
||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
|
||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const address = email.input.value.trim();
|
||||
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
|
||||
if (problem) {
|
||||
showError(panel, problem);
|
||||
(problem === MISMATCH ? repeat : password).input.focus();
|
||||
return;
|
||||
}
|
||||
void run(
|
||||
panel,
|
||||
'register',
|
||||
async () => {
|
||||
await register(address, password.input.value);
|
||||
finish(true);
|
||||
},
|
||||
(err) => {
|
||||
if (errorType(err) !== 'user_already_exists') return undefined;
|
||||
return linkButton('Anmelden', () => showView('login'));
|
||||
},
|
||||
);
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
|
||||
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
|
||||
const panel: Panel = { form, first: email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const address = email.input.value.trim();
|
||||
void run(panel, 'recover', async () => {
|
||||
await requestPasswordReset(address);
|
||||
showOk(panel, RESET_SENT);
|
||||
});
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
|
||||
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
|
||||
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
|
||||
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
if (typed) draftEmail = typed.value.trim();
|
||||
view = next;
|
||||
title.textContent = TITLES[next];
|
||||
const panel = buildPanel(next);
|
||||
if (next === 'forgot') {
|
||||
content.replaceChildren(panel.form);
|
||||
} else {
|
||||
const body = el('div', { id: 'auth-panel', role: 'tabpanel', 'aria-labelledby': `auth-tab-${next}` }, panel.form);
|
||||
content.replaceChildren(buildTabs(), body);
|
||||
}
|
||||
if (focus === 'tab') content.querySelector<HTMLElement>(`#auth-tab-${next}`)?.focus();
|
||||
else panel.first.focus();
|
||||
}
|
||||
|
||||
return (initial) => {
|
||||
if (pending) return pending;
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
draftEmail = '';
|
||||
busy = false;
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
showView(initial);
|
||||
dialog.showModal();
|
||||
// showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld.
|
||||
content.querySelector<HTMLElement>('input')?.focus();
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
|
||||
let open: ((view: LoginView) => Promise<boolean>) | null = null;
|
||||
|
||||
/**
|
||||
* Öffnet den Login-Dialog (beim ersten Aufruf erzeugt). Gibt true zurück, sobald sich der Nutzer angemeldet
|
||||
* oder registriert hat, und false, wenn er den Dialog schließt. Gleichzeitige Aufrufe teilen sich ein Ergebnis.
|
||||
*/
|
||||
export function requireLogin(view: LoginView = 'login'): Promise<boolean> {
|
||||
open ??= createLoginDialog();
|
||||
return open(view);
|
||||
}
|
||||
+21
-8
@@ -1,24 +1,37 @@
|
||||
import '@fontsource-variable/nunito-sans';
|
||||
import './styles.css';
|
||||
import { startRouter, type RouteId } from './pages/router';
|
||||
import { mountAccountMenu } from './account/account-menu';
|
||||
import { getCurrentUser } from './account/auth';
|
||||
import { startRouter } from './pages/router';
|
||||
import { mountResetPage } from './pages/reset-page';
|
||||
import { mountEditor } from './editor/editor-page';
|
||||
import { createStore, initialEditorState } from './editor/state';
|
||||
|
||||
// Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind.
|
||||
const PLACEHOLDER_TITLES: Record<Exclude<RouteId, 'editor'>, string> = {
|
||||
cards: 'Meine Karten',
|
||||
reset: 'Passwort neu setzen',
|
||||
};
|
||||
|
||||
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
|
||||
const editorStore = createStore(initialEditorState());
|
||||
|
||||
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
|
||||
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
|
||||
const accountSlot = document.getElementById('account-slot');
|
||||
if (accountSlot) {
|
||||
const menu = mountAccountMenu(accountSlot);
|
||||
getCurrentUser()
|
||||
.catch((error: unknown) => {
|
||||
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
||||
})
|
||||
.finally(() => menu.ready());
|
||||
}
|
||||
|
||||
startRouter((route, root) => {
|
||||
if (route === 'editor') {
|
||||
// Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts.
|
||||
return mountEditor(root, editorStore, { onSave: () => {} });
|
||||
}
|
||||
if (route === 'reset') {
|
||||
return mountResetPage(root);
|
||||
}
|
||||
// Platzhalter, bis die Karten-Seite aus der Folgeaufgabe angebunden ist.
|
||||
const heading = document.createElement('h1');
|
||||
heading.textContent = PLACEHOLDER_TITLES[route];
|
||||
heading.textContent = 'Meine Karten';
|
||||
root.replaceChildren(heading);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createEmailPasswordSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: mocks }));
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
|
||||
type Page = typeof import('./reset-page');
|
||||
let mountResetPage: Page['mountResetPage'];
|
||||
let root: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.resetModules();
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
root = document.getElementById('app-root')!;
|
||||
history.replaceState(null, '', '/passwort-neu');
|
||||
({ mountResetPage } = await import('./reset-page'));
|
||||
});
|
||||
|
||||
const withLink = () => history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret');
|
||||
const input = (name: string) => root.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const alertText = () => root.querySelector('[role="alert"]')!.textContent;
|
||||
const submit = () => root.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const fill = (password: string, repeat = password) => {
|
||||
input('password').value = password;
|
||||
input('password-repeat').value = repeat;
|
||||
};
|
||||
const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!;
|
||||
|
||||
describe('ohne gültigen Link', () => {
|
||||
it('ohne Parameter: Hinweis und Knopf „Neuen Link anfordern“, der den Dialog öffnet', () => {
|
||||
mountResetPage(root);
|
||||
expect(root.querySelector('h1')!.textContent).toBe('Passwort neu setzen');
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
expect(root.querySelector('form')).toBeNull();
|
||||
button('Neuen Link anfordern').click();
|
||||
const dialog = document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.querySelector('h2')!.textContent).toBe('Passwort vergessen');
|
||||
});
|
||||
|
||||
it('mit nur einem der beiden Parameter ebenso', () => {
|
||||
history.replaceState(null, '', '/passwort-neu?userId=u1');
|
||||
mountResetPage(root);
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mit Link', () => {
|
||||
it('zeigt zwei Passwortfelder mit new-password', () => {
|
||||
withLink();
|
||||
mountResetPage(root);
|
||||
expect(input('password').autocomplete).toBe('new-password');
|
||||
expect(input('password-repeat').autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
it('prüft vor dem Senden', () => {
|
||||
withLink();
|
||||
mountResetPage(root);
|
||||
fill('geheim123', 'geheim124');
|
||||
submit();
|
||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||
fill('kurz');
|
||||
submit();
|
||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(input('password').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(mocks.updateRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setzt das Passwort, zeigt den Erfolg, entfernt das Geheimnis aus der Adresse und bietet „Anmelden“', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockResolvedValue({});
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() =>
|
||||
expect(root.querySelector('[role="status"]')!.textContent).toBe('Passwort geändert. Du kannst dich jetzt anmelden.'),
|
||||
);
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 's3cret', password: 'neuesPasswort1' });
|
||||
expect(location.search).toBe('');
|
||||
button('Anmelden').click();
|
||||
const dialog = document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.querySelector('h2')!.textContent).toBe('Anmelden');
|
||||
});
|
||||
|
||||
it('sperrt das Formular, solange die Anfrage läuft', async () => {
|
||||
withLink();
|
||||
let release!: () => void;
|
||||
mocks.updateRecovery.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
submit();
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledTimes(1);
|
||||
expect(root.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
release();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="status"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user_invalid_token', 401, ''],
|
||||
['user_not_found', 404, ''],
|
||||
['general_argument_invalid', 400, 'Invalid `userId` param: Parameter must be a valid number'],
|
||||
['general_argument_invalid', 400, 'Invalid `secret` param: Value must be a valid string'],
|
||||
])('Link-Fehler %s (%s) zeigt „Link ungültig“ und den Knopf „Neuen Link anfordern“', async (type, code, message) => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(fail(code, type, message));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(root.querySelector('form')).toBeNull());
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
expect(button('Neuen Link anfordern')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ein Passwortfehler vom Server steht am Feld, das Formular bleibt', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(
|
||||
fail(
|
||||
400,
|
||||
'general_argument_invalid',
|
||||
'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.',
|
||||
),
|
||||
);
|
||||
mountResetPage(root);
|
||||
fill('password123');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'));
|
||||
expect(root.querySelector('form')).not.toBeNull();
|
||||
expect(input('password').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(root.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Netzfehler: Meldung, Formular bleibt', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
|
||||
expect(root.querySelector('form')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Seite /passwort-neu: neues Passwort setzen mit dem Link aus der Mail (?userId=…&secret=…).
|
||||
import '../account/account.css';
|
||||
import { completePasswordReset, validatePassword } from '../account/auth';
|
||||
import { errorMessage, isInvalidLinkError } from '../account/errors';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { navigate } from './router';
|
||||
|
||||
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
|
||||
|
||||
export function mountResetPage(root: HTMLElement): () => void {
|
||||
let disposed = false;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const userId = params.get('userId') ?? '';
|
||||
const secret = params.get('secret') ?? '';
|
||||
|
||||
const heading = el('h1', {}, 'Passwort neu setzen');
|
||||
const page = el('section', { class: 'reset-page' });
|
||||
root.replaceChildren(heading, page);
|
||||
|
||||
function showInvalidLink(): void {
|
||||
const again = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Neuen Link anfordern');
|
||||
again.addEventListener('click', () => void requireLogin('forgot'));
|
||||
page.replaceChildren(
|
||||
el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }, 'Der Link ist ungültig oder abgelaufen.'),
|
||||
el('p', { class: 'reset-actions' }, again),
|
||||
);
|
||||
}
|
||||
|
||||
function showDone(): void {
|
||||
// Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben.
|
||||
history.replaceState(null, '', location.pathname);
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden');
|
||||
signIn.addEventListener('click', () => {
|
||||
void requireLogin('login').then((ok) => {
|
||||
if (ok && !disposed) navigate('/');
|
||||
});
|
||||
});
|
||||
page.replaceChildren(
|
||||
el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }, 'Passwort geändert. Du kannst dich jetzt anmelden.'),
|
||||
el('p', { class: 'reset-actions' }, signIn),
|
||||
);
|
||||
}
|
||||
|
||||
function showForm(): void {
|
||||
const password = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
autocomplete: 'new-password',
|
||||
required: true,
|
||||
'aria-describedby': 'reset-hint reset-error',
|
||||
});
|
||||
const repeat = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'password',
|
||||
name: 'password-repeat',
|
||||
autocomplete: 'new-password',
|
||||
required: true,
|
||||
'aria-describedby': 'reset-error',
|
||||
});
|
||||
const hint = el('p', { class: 'auth-hint', id: 'reset-hint' }, 'Mindestens 8 Zeichen.');
|
||||
const error = el('p', { class: 'auth-msg auth-msg-error', id: 'reset-error', role: 'alert' });
|
||||
const submit = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Passwort speichern');
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'auth-form' },
|
||||
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort'), password),
|
||||
hint,
|
||||
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort wiederholen'), repeat),
|
||||
error,
|
||||
submit,
|
||||
);
|
||||
|
||||
let busy = false;
|
||||
const setBusy = (value: boolean) => {
|
||||
busy = value;
|
||||
submit.disabled = value;
|
||||
password.readOnly = value;
|
||||
repeat.readOnly = value;
|
||||
form.setAttribute('aria-busy', String(value));
|
||||
};
|
||||
const fail = (text: string, field: HTMLInputElement) => {
|
||||
error.textContent = text;
|
||||
password.setAttribute('aria-invalid', String(field === password));
|
||||
repeat.setAttribute('aria-invalid', String(field === repeat));
|
||||
field.focus();
|
||||
};
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
error.textContent = '';
|
||||
password.removeAttribute('aria-invalid');
|
||||
repeat.removeAttribute('aria-invalid');
|
||||
const problem = password.value !== repeat.value ? MISMATCH : validatePassword(password.value);
|
||||
if (problem) {
|
||||
fail(problem, problem === MISMATCH ? repeat : password);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
completePasswordReset(userId, secret, password.value).then(
|
||||
() => {
|
||||
if (!disposed) showDone();
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (disposed) return;
|
||||
// Link-Fehler ersetzen das Formular; Passwort-, Netz- und sonstige Fehler stehen am Feld.
|
||||
if (isInvalidLinkError(err)) {
|
||||
showInvalidLink();
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
fail(errorMessage(err, 'reset'), password);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
page.replaceChildren(form);
|
||||
}
|
||||
|
||||
if (userId && secret) showForm();
|
||||
else showInvalidLink();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}
|
||||
Vendored
+11
@@ -1 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Appwrite-Endpoint; ohne Angabe `${location.origin}/v1` (Produktion: https://aircard-ios.org/v1). */
|
||||
readonly VITE_APPWRITE_ENDPOINT?: string;
|
||||
/** Appwrite-Projekt-ID; ohne Angabe `aircard`. */
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in new issue
Block a user