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

+140
View File
@@ -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);
});
});
+53 -10
View File
@@ -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
View File
@@ -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;
}
}
+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');
+14 -4
View File
@@ -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;
}