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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:39:09 +00:00
1 parent 1a64e12bc5
commit 25ff496548
30 files changed
+1783 -189

No files matched your search

+54 -8
View File
@@ -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');