feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor); Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel bauen die Seite nicht neu auf. - Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf, Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue. - Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle Tokens, danger-/warn-/ok-text-Tokens statt fester Farben. - Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg. - User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a64e12bc5
commit
25ff496548
30 files changed
+1783
-189
No files matched your search
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type TestUser = { $id: string; email: string; labels?: string[] };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
user: null as TestUser | null,
|
||||
listeners: new Set<(user: TestUser | null) => void>(),
|
||||
logout: vi.fn(),
|
||||
requireLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./auth', () => ({
|
||||
currentUser: () => mocks.user,
|
||||
logout: mocks.logout,
|
||||
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||
mocks.listeners.add(fn);
|
||||
return () => mocks.listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
|
||||
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
||||
|
||||
let slot: HTMLElement;
|
||||
let menu: AccountMenu;
|
||||
|
||||
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
|
||||
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
|
||||
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
|
||||
|
||||
function setUser(user: TestUser | null) {
|
||||
mocks.user = user;
|
||||
for (const fn of [...mocks.listeners]) fn(user);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.user = null;
|
||||
mocks.listeners.clear();
|
||||
mocks.logout.mockReset();
|
||||
mocks.requireLogin.mockReset();
|
||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||
slot = document.getElementById('account-slot') as HTMLElement;
|
||||
menu = mountAccountMenu(slot);
|
||||
});
|
||||
|
||||
describe('Konto-Menü', () => {
|
||||
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
|
||||
menu.ready();
|
||||
buttonByText('Anmelden')!.click();
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
expect(avatar()).toBeNull();
|
||||
});
|
||||
|
||||
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
expect(avatar()?.textContent).toBe('K');
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||
expect(link.textContent).toBe('Meine Karten');
|
||||
expect(link.getAttribute('href')).toBe('/karten');
|
||||
expect(buttonByText('Abmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(document.activeElement).toBe(avatar());
|
||||
});
|
||||
|
||||
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const signOut = buttonByText('Abmelden')!;
|
||||
signOut.click();
|
||||
expect(mocks.logout).toHaveBeenCalledOnce();
|
||||
expect(signOut.disabled).toBe(true);
|
||||
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
|
||||
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
setUser(null);
|
||||
expect(avatar()).toBeNull();
|
||||
expect(buttonByText('Anmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('dispose leert den Slot und meldet den Listener ab', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
menu.dispose();
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
expect(mocks.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
|
||||
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { currentUser, logout, onAuthChange } from './auth';
|
||||
@@ -13,8 +14,12 @@ export interface AccountMenu {
|
||||
|
||||
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
let isReady = false;
|
||||
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
|
||||
let detach: (() => void) | null = null;
|
||||
|
||||
function render(): void {
|
||||
detach?.();
|
||||
detach = null;
|
||||
if (!isReady) {
|
||||
slot.replaceChildren();
|
||||
return;
|
||||
@@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
|
||||
const avatar = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'account-avatar',
|
||||
'aria-label': 'Konto-Menü',
|
||||
'aria-expanded': 'false',
|
||||
'aria-controls': 'account-dropdown',
|
||||
},
|
||||
initial,
|
||||
);
|
||||
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 myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
|
||||
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
|
||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
|
||||
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
|
||||
|
||||
let isOpen = false;
|
||||
function setOpen(open: boolean): void {
|
||||
isOpen = open;
|
||||
dropdown.hidden = !open;
|
||||
avatar.setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
avatar.addEventListener('click', () => setOpen(!isOpen));
|
||||
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
if (!isOpen) return;
|
||||
const target = event.target as Element | null;
|
||||
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || !isOpen) return;
|
||||
setOpen(false);
|
||||
avatar.focus();
|
||||
};
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
detach = () => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
|
||||
signOut.addEventListener('click', () => {
|
||||
signOut.disabled = true;
|
||||
error.textContent = '';
|
||||
@@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
signOut.disabled = false;
|
||||
});
|
||||
});
|
||||
slot.replaceChildren(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
|
||||
el('div', { class: 'account-row' }, email, signOut),
|
||||
error,
|
||||
),
|
||||
);
|
||||
slot.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
const off = onAuthChange(render);
|
||||
@@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
},
|
||||
dispose() {
|
||||
off();
|
||||
detach?.();
|
||||
detach = null;
|
||||
slot.replaceChildren();
|
||||
},
|
||||
};
|
||||
|
||||
+86
-38
@@ -25,6 +25,7 @@
|
||||
.auth-btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@
|
||||
|
||||
.auth-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: #ff9a9d;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.auth-btn-danger:hover:not(:disabled) {
|
||||
@@ -70,43 +71,104 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Konto-Menü im Seitenkopf */
|
||||
|
||||
#account-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
|
||||
|
||||
.account-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
.account-avatar {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.account-avatar:hover,
|
||||
.account-avatar[aria-expanded='true'] {
|
||||
border-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.account-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, calc(100vw - 32px));
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.account-dropdown[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
min-width: 0;
|
||||
max-width: 28ch;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 12px 10px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-item {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-item:hover:not(:disabled) {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.account-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.account-error {
|
||||
color: #ff9a9d;
|
||||
padding: 4px 12px 0;
|
||||
color: var(--danger-text);
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-error:empty {
|
||||
@@ -124,11 +186,11 @@
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.auth-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
background: var(--backdrop);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -261,11 +323,11 @@
|
||||
}
|
||||
|
||||
.auth-msg-error {
|
||||
color: #ff9a9d;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.auth-msg-ok {
|
||||
color: #8fd9a8;
|
||||
color: var(--ok-text);
|
||||
}
|
||||
|
||||
.auth-aside {
|
||||
@@ -289,29 +351,15 @@
|
||||
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 {
|
||||
.auth-btn,
|
||||
.account-avatar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ 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' });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Labels und isAdmin', () => {
|
||||
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
|
||||
});
|
||||
|
||||
it('login liefert die Labels ebenfalls', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
|
||||
expect(auth.currentUser()?.labels).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
});
|
||||
|
||||
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.isAdmin(null)).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
@@ -87,10 +133,10 @@ describe('login', () => {
|
||||
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' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
});
|
||||
|
||||
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
||||
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.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' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
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' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.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' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -188,7 +234,7 @@ describe('register', () => {
|
||||
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' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||
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');
|
||||
|
||||
+14
-4
@@ -4,7 +4,7 @@ import { ID } from 'appwrite';
|
||||
import { account } from './appwrite';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
|
||||
export type User = { $id: string; email: string };
|
||||
export type User = { $id: string; email: string; labels?: string[] };
|
||||
|
||||
const PASSWORD_MIN = 8;
|
||||
const PASSWORD_MAX = 256;
|
||||
@@ -27,10 +27,16 @@ function setUser(user: User | null): void {
|
||||
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 };
|
||||
const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
|
||||
return { $id: data.$id, email: data.email, labels };
|
||||
}
|
||||
|
||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
|
||||
/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
|
||||
export function isAdmin(user: User | null): boolean {
|
||||
return (user?.labels ?? []).includes('admin');
|
||||
}
|
||||
|
||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
let next: User | null;
|
||||
try {
|
||||
@@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise<User | null> {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
next = null;
|
||||
}
|
||||
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
|
||||
const same =
|
||||
next?.$id === cachedUser?.$id &&
|
||||
next?.email === cachedUser?.email &&
|
||||
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
|
||||
if (!same) setUser(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user