Compare commits

...
3 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 a147d3b496 fix(account): Login bei bereits aktiver Sitzung, showModal-Fehler abfangen
- login(): bei user_session_already_exists aktuelle Sitzung beenden und genau
  einmal neu versuchen; Meldung für den Restfall in errorMessage
- requireLogin(): schlägt showModal fehl, wird aufgeräumt und der Fehler geworfen,
  damit spätere Aufrufe nicht an einem toten Promise hängen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:02:42 +00:00
ClaudeandClaude Opus 5.5 10c55ca933 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>
2026-10-04 08:54:10 +00:00
ClaudeandClaude Opus 5.5 d1d4d621a7 feat(appwrite): Setup-Skript und Console-Anleitung
web/scripts/appwrite-setup.mjs legt Datenbank aircard, Tabelle cards
(4 Spalten) und Bucket card-images idempotent per fetch an, wartet auf
Spalten-Status available und meldet Abweichungen, ohne sie zu ändern
(--check: nichts anlegen, Exit 1 bei Fehlendem/Abweichendem).
docs/appwrite-console.md ist die Klickliste für Projekt, Plattformen,
Auth, Dienste und API-Key. Tests laufen mit node --test gegen einen
Fake-Appwrite; Vitest nimmt nur src/**/*.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:39:02 +00:00
19 changed files with 3100 additions and 10 deletions

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# Appwrite-Console: Projekt „AirCard“ einrichten
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
## 1. Projekt anlegen
1. In der Console anmelden und die bestehende Organisation öffnen.
2. **Create project** klicken.
3. Name: `AirCard`.
4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen.
5. **Create** klicken.
## 2. Web-Plattformen eintragen
Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**.
1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**.
2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**.
## 3. Auth-Einstellungen
Linke Leiste → **Auth** → **Settings**.
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
2. **Session duration**: `30` Tage → **Update**.
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
4. **Limit** (Nutzerlimit): `2000` → **Update**.
5. **Password dictionary**: einschalten.
Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen.
## 4. Nicht benötigte Dienste abschalten
Linke Leiste → **Settings** → Reiter **Services**.
1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an.
## 5. API-Key `aircard-provision` anlegen
Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
2. Name: `aircard-provision`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese acht ankreuzen (Gruppe Database bzw. Storage):
- `databases.read` und `databases.write`
- `tables.read` und `tables.write`
- `columns.read` und `columns.write`
- `buckets.read` und `buckets.write`
4. **Create** klicken.
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
## 6. Key auf dem Server eintragen
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`:
```
AIRCARD_APPWRITE_API_KEY=<hier den kopierten Secret-Wert einfügen>
```
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`.
## 7. Bescheid geben
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie.
+29 -1
View File
@@ -8,7 +8,8 @@
"name": "aircard-web",
"version": "0.1.0",
"dependencies": {
"@fontsource-variable/nunito-sans": "5.3.0"
"@fontsource-variable/nunito-sans": "5.3.0",
"appwrite": "22.4.1"
},
"devDependencies": {
"happy-dom": "20.14.5",
@@ -434,6 +435,15 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/appwrite": {
"version": "22.4.1",
"resolved": "https://registry.npmjs.org/appwrite/-/appwrite-22.4.1.tgz",
"integrity": "sha512-NlEgUvSo7A1m+TEHZ8zlWUIuCDtNg3VN6Mbxd6r+1618CiWokig630FpeKQ9/WD5Io92QxuTV/dVzBLPlQghqg==",
"license": "BSD-3-Clause",
"dependencies": {
"json-bigint": "1.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -444,6 +454,15 @@
"node": ">=12"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
@@ -569,6 +588,15 @@
"node": ">=20.0.0"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+2 -1
View File
@@ -10,7 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/nunito-sans": "5.3.0"
"@fontsource-variable/nunito-sans": "5.3.0",
"appwrite": "22.4.1"
},
"devDependencies": {
"happy-dom": "20.14.5",
+448
View File
@@ -0,0 +1,448 @@
#!/usr/bin/env node
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
* node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]
*
* Umgebung:
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
*
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
*
* Ausgabe: eine Zeile je Objekt –
* OK: … FEHLT: … ANGELEGT: … ABWEICHUNG: <feld> ist <x>, erwartet <y>
*
* Exit-Codes: 0 = Schema vollständig, 1 = fehlt/weicht ab, 2 = Abbruch
* (fehlender Key, ungültiger Aufruf, Server-/Zugriffsfehler).
*
* Der API-Key wird nie ausgegeben (weder Wert noch Header).
*/
import { pathToFileURL } from 'node:url';
export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
const PROJECT = 'aircard';
// ---------------------------------------------------------------------------
// Soll-Zustand
// ---------------------------------------------------------------------------
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
table: {
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [
{ key: 'name', size: 80, required: true },
{ key: 'config', size: 2000, required: true },
{ key: 'imageFileId', size: 36, required: true },
{ key: 'previewFileId', size: 36, required: true },
],
},
bucket: {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
};
// ---------------------------------------------------------------------------
// Reine Helfer (ohne Netzwerk, einzeln testbar)
// ---------------------------------------------------------------------------
/** 404 heißt „Objekt fehlt" – außer der ganze Projekt-Kontext fehlt (project_not_found). */
export function isMissing(status, body) {
return status === 404 && body?.type !== 'project_not_found';
}
const fmt = (v) => {
if (typeof v === 'string') return v;
if (Array.isArray(v)) return `[${v.join(', ')}]`;
return JSON.stringify(v) ?? 'undefined';
};
const sorted = (list) => [...(list ?? [])].sort();
const sameList = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
function mismatch(field, actual, expected) {
return `${field} ist ${fmt(actual)}, erwartet ${fmt(expected)}`;
}
/** Vergleich der Tabelle: `$permissions` (Reihenfolge egal) und `rowSecurity`. */
export function compareTable(actual, desired) {
const out = [];
if (!sameList(actual.$permissions, desired.permissions)) {
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
}
if (actual.rowSecurity !== desired.rowSecurity) {
out.push(mismatch('rowSecurity', actual.rowSecurity, desired.rowSecurity));
}
return out;
}
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
export function compareColumn(actual, desired) {
const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
/** Vergleich des Buckets: Rechte, `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). */
export function compareBucket(actual, desired) {
const out = [];
if (!sameList(actual.$permissions, desired.permissions)) {
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
}
if (actual.fileSecurity !== desired.fileSecurity) {
out.push(mismatch('fileSecurity', actual.fileSecurity, desired.fileSecurity));
}
if (actual.maximumFileSize !== desired.maximumFileSize) {
out.push(mismatch('maximumFileSize', actual.maximumFileSize, desired.maximumFileSize));
}
if (!sameList(actual.allowedFileExtensions, desired.allowedFileExtensions)) {
out.push(mismatch('allowedFileExtensions', sorted(actual.allowedFileExtensions), sorted(desired.allowedFileExtensions)));
}
return out;
}
// ---------------------------------------------------------------------------
// Zugriff auf die Appwrite-REST-API
// ---------------------------------------------------------------------------
const REQUEST_TIMEOUT_MS = 20_000;
/** Abbruch mit verständlicher Meldung (Exit 2). Enthält nie den Key. */
class SetupError extends Error {}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** `api(method, path, body?)` → `{ status, body }` für Aufrufe gegen Endpoint + Pfad. */
function createApi({ endpoint, key, project }) {
const base = endpoint.replace(/\/+$/, '');
return async function api(method, path, body) {
let res;
try {
res = await fetch(`${base}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Appwrite-Project': project,
'X-Appwrite-Key': key,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
// Nur Fehlercode bzw. -name ausgeben, nie das Request-Objekt.
throw new SetupError(`Appwrite nicht erreichbar (${base}): ${err?.cause?.code ?? err?.name ?? 'unbekannter Fehler'}.`);
}
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : undefined;
} catch {
json = { message: text.replace(/\s+/g, ' ').slice(0, 200) };
}
if (typeof json?.message === 'string') json.message = json.message.split(key).join('***');
return { status: res.status, body: json };
};
}
/** Klare deutsche Meldung für eine unerwartete Antwort. */
function describeFailure(status, body, what) {
const detail = [body?.type, body?.message].filter((part) => typeof part === 'string' && part).join(': ');
const tail = detail ? ` Server: ${detail}` : '';
if (status === 404 && body?.type === 'project_not_found') {
return `Das Projekt „${PROJECT}“ existiert auf dem Server nicht. Bitte zuerst docs/appwrite-console.md abarbeiten (Projekt mit der ID „${PROJECT}“ anlegen).`;
}
if (status === 401 || status === 403) {
return `${what}: Zugriff verweigert (HTTP ${status}). API-Key und Scopes prüfen (databases, tables, columns, buckets – jeweils read/write).${tail}`;
}
if (status >= 500) return `${what}: Serverfehler (HTTP ${status}).${tail}`;
return `${what}: unerwartete Antwort (HTTP ${status}).${tail}`;
}
/** GET: Objekt oder `null` (fehlt). Jeder andere Fehler bricht ab. */
async function getObject(ctx, path, what) {
const res = await ctx.api('GET', path);
if (res.status === 200) return res.body;
if (isMissing(res.status, res.body)) return null;
throw new SetupError(describeFailure(res.status, res.body, what));
}
async function createObject(ctx, path, body, what) {
const res = await ctx.api('POST', path, body);
if (res.status >= 200 && res.status < 300) return res.body;
throw new SetupError(describeFailure(res.status, res.body, what));
}
// ---------------------------------------------------------------------------
// Objekte sicherstellen
// ---------------------------------------------------------------------------
const DB_ID = DESIRED.database.id;
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
export async function ensureDatabase(ctx) {
const { id, name } = DESIRED.database;
const label = `Datenbank ${id}`;
if (await getObject(ctx, `/tablesdb/${id}`, label)) {
ctx.ok(label);
return true;
}
if (ctx.check) {
ctx.missing(label);
return false;
}
await createObject(ctx, '/tablesdb', { databaseId: id, name }, label);
ctx.created(label);
return true;
}
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
export async function ensureTable(ctx, parentExists) {
const t = DESIRED.table;
const label = `Tabelle ${t.id}`;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
if (actual) {
const deviations = compareTable(actual, t);
if (deviations.length) ctx.deviations(t.id, deviations);
else ctx.ok(label);
return true;
}
if (ctx.check) {
ctx.missing(label);
return false;
}
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
label,
);
ctx.created(label);
return true;
}
/**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
*/
export async function waitAvailable(ctx, tableId, keys) {
const deadline = Date.now() + ctx.timeoutMs;
const done = new Map();
let pending = [...keys];
for (;;) {
const still = [];
for (const key of pending) {
const label = `Spalte ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(key), label);
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
if (actual.status === 'available') {
done.set(key, actual);
} else if (actual.status === 'failed' || actual.status === 'stuck') {
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
} else {
still.push(key);
}
}
if (!still.length) return done;
if (Date.now() >= deadline) {
throw new SetupError(
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
);
}
pending = still;
await sleep(ctx.pollMs);
}
}
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
*/
export async function ensureColumns(ctx, parentExists) {
const t = DESIRED.table;
const entries = [];
for (const col of t.columns) {
const label = `Spalte ${t.id}.${col.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
if (actual) {
entries.push({ col, label, actual, created: false });
} else if (ctx.check) {
ctx.missing(label);
} else {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
ctx.created(label);
entries.push({ col, label, actual: null, created: true });
}
}
if (!ctx.check) {
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
if (waiting.length) {
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
for (const e of waiting) e.actual = ready.get(e.col.key);
}
}
for (const e of entries) {
const deviations = compareColumn(e.actual, e.col);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
else if (!e.created) ctx.ok(e.label);
}
}
export async function ensureBucket(ctx) {
const b = DESIRED.bucket;
const label = `Bucket ${b.id}`;
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
if (actual) {
const deviations = compareBucket(actual, b);
if (deviations.length) ctx.deviations(b.id, deviations);
else ctx.ok(label);
return;
}
if (ctx.check) {
ctx.missing(label);
return;
}
await createObject(
ctx,
'/storage/buckets',
{
bucketId: b.id,
name: b.name,
permissions: b.permissions,
fileSecurity: b.fileSecurity,
maximumFileSize: b.maximumFileSize,
allowedFileExtensions: b.allowedFileExtensions,
compression: b.compression,
antivirus: b.antivirus,
},
label,
);
ctx.created(label);
}
// ---------------------------------------------------------------------------
// Ablauf
// ---------------------------------------------------------------------------
/**
* Führt das Setup (oder mit `check` nur die Prüfung) aus und gibt den Exit-Code
* zurück: 0 vollständig, 1 fehlt/weicht ab, 2 Abbruch.
*/
export async function main({
endpoint = DEFAULT_ENDPOINT,
key,
project = PROJECT,
check = false,
log = console.log,
pollMs = 1000,
timeoutMs = 30_000,
} = {}) {
const stats = { missing: 0, deviations: 0 };
const ctx = {
api: createApi({ endpoint, key, project }),
check,
pollMs,
timeoutMs,
ok: (label) => log(`OK: ${label}`),
created: (label) => log(`ANGELEGT: ${label}`),
missing: (label) => {
stats.missing += 1;
log(`FEHLT: ${label}`);
},
deviations: (prefix, list) => {
for (const text of list) {
stats.deviations += 1;
log(`ABWEICHUNG: ${prefix}.${text}`);
}
},
};
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
try {
const databaseExists = await ensureDatabase(ctx);
const tableExists = await ensureTable(ctx, databaseExists);
await ensureColumns(ctx, tableExists);
await ensureBucket(ctx);
} catch (err) {
if (!(err instanceof SetupError)) throw err;
log(`ABBRUCH: ${err.message}`);
return 2;
}
if (stats.missing || stats.deviations) {
const parts = [];
if (stats.missing) parts.push(`${stats.missing} fehlend`);
if (stats.deviations) parts.push(`${stats.deviations} Abweichung(en)`);
log(`UNVOLLSTÄNDIG: ${parts.join(', ')}.`);
if (stats.missing) log('Zum Anlegen das Skript ohne --check starten.');
if (stats.deviations) log('Abweichungen werden nie automatisch geändert – bitte in der Console korrigieren.');
return 1;
}
log('OK: Schema vollständig');
return 0;
}
// ---------------------------------------------------------------------------
// Kommandozeile
// ---------------------------------------------------------------------------
async function cli() {
const args = process.argv.slice(2);
const unknown = args.filter((arg) => arg !== '--check');
if (unknown.length) {
console.error(`Unbekannte Option: ${unknown.join(' ')}`);
console.error('Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]');
process.exitCode = 2;
return;
}
const key = process.env.AIRCARD_APPWRITE_API_KEY?.trim();
if (!key) {
console.error('Fehler: AIRCARD_APPWRITE_API_KEY fehlt.');
console.error('Den API-Key „aircard-provision“ als Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen (siehe docs/appwrite-console.md)');
console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.');
process.exitCode = 2;
return;
}
process.exitCode = await main({
endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT,
key,
check: args.includes('--check'),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
await cli();
} catch (err) {
console.error(`Unerwarteter Fehler: ${err?.message ?? err}`);
process.exitCode = 2;
}
}
+579
View File
@@ -0,0 +1,579 @@
// Tests für web/scripts/appwrite-setup.mjs. Aufruf: node --test web/scripts/appwrite-setup.test.mjs
// Nur Node-Bordmittel. Der "Appwrite-Server" ist ein Fake auf Port 0 (Speicher im Prozess),
// es wird nie ein echter Server oder ein echter Key benutzt.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
DESIRED,
DEFAULT_ENDPOINT,
isMissing,
compareTable,
compareColumn,
compareBucket,
main,
} from './appwrite-setup.mjs';
const SCRIPT = fileURLToPath(new URL('./appwrite-setup.mjs', import.meta.url));
const KEY = 'test-key-0123456789abcdef-geheim';
// ---------------------------------------------------------------------------
// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher)
// ---------------------------------------------------------------------------
function createFakeAppwrite({ project = 'aircard', key = KEY, stuckColumns = false, inject = null } = {}) {
const state = {
databases: new Map(),
tables: new Map(), // "db/table"
columns: new Map(), // "db/table/key"
buckets: new Map(),
requests: [],
};
const send = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
const fail = (res, status, type, message) => send(res, status, { message, code: status, type, version: '1.8.1' });
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString();
const body = raw ? JSON.parse(raw) : undefined;
const path = new URL(req.url, 'http://fake').pathname;
state.requests.push({ method: req.method, path, body, headers: req.headers });
if (req.headers['x-appwrite-project'] !== project) {
return fail(res, 404, 'project_not_found', 'Project with the requested ID could not be found.');
}
if (req.headers['x-appwrite-key'] !== key) {
return fail(res, 401, 'user_unauthorized', 'The current user is not authorized to perform the requested action.');
}
const injected = inject?.(req.method, path);
if (injected) return fail(res, injected.status, injected.type, injected.message ?? 'injected');
const seg = path.replace(/^\/v1/, '').split('/').filter(Boolean);
const m = req.method;
// POST /tablesdb
if (m === 'POST' && seg.length === 1 && seg[0] === 'tablesdb') {
if (state.databases.has(body.databaseId)) return fail(res, 409, 'database_already_exists', 'exists');
const db = { $id: body.databaseId, name: body.name, enabled: true };
state.databases.set(db.$id, db);
return send(res, 201, db);
}
// GET /tablesdb/:db
if (m === 'GET' && seg.length === 2 && seg[0] === 'tablesdb') {
const db = state.databases.get(seg[1]);
return db ? send(res, 200, db) : fail(res, 404, 'database_not_found', 'Database not found');
}
// POST /tablesdb/:db/tables
if (m === 'POST' && seg.length === 3 && seg[0] === 'tablesdb' && seg[2] === 'tables') {
if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found');
const id = `${seg[1]}/${body.tableId}`;
if (state.tables.has(id)) return fail(res, 409, 'table_already_exists', 'exists');
const table = {
$id: body.tableId, name: body.name, $permissions: body.permissions ?? [],
rowSecurity: body.rowSecurity ?? true, enabled: true,
};
state.tables.set(id, table);
return send(res, 201, table);
}
// GET /tablesdb/:db/tables/:t
if (m === 'GET' && seg.length === 4 && seg[0] === 'tablesdb' && seg[2] === 'tables') {
if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found');
const table = state.tables.get(`${seg[1]}/${seg[3]}`);
return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found');
}
// POST /tablesdb/:db/tables/:t/columns/string
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const id = `${seg[1]}/${seg[3]}/${body.key}`;
if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists');
const col = { key: body.key, type: 'string', size: body.size, required: body.required, array: false, status: 'processing', gets: 0 };
state.columns.set(id, col);
const { gets, ...out } = col;
return send(res, 202, out);
}
// GET /tablesdb/:db/tables/:t/columns/:key
if (m === 'GET' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!col) return fail(res, 404, 'column_not_found', 'Column not found');
col.gets += 1;
const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing';
const { gets, ...out } = col;
return send(res, 200, { ...out, status });
}
// POST /storage/buckets
if (m === 'POST' && path === '/v1/storage/buckets') {
if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists');
const bucket = {
$id: body.bucketId, name: body.name, $permissions: body.permissions ?? [],
fileSecurity: body.fileSecurity, maximumFileSize: body.maximumFileSize,
allowedFileExtensions: body.allowedFileExtensions, compression: body.compression,
antivirus: body.antivirus, enabled: true,
};
state.buckets.set(bucket.$id, bucket);
return send(res, 201, bucket);
}
// GET /storage/buckets/:id
if (m === 'GET' && seg.length === 3 && seg[0] === 'storage' && seg[1] === 'buckets') {
const bucket = state.buckets.get(seg[2]);
return bucket ? send(res, 200, bucket) : fail(res, 404, 'storage_bucket_not_found', 'Bucket not found');
}
return fail(res, 404, 'general_route_not_found', 'Route not found');
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
resolve({
state,
endpoint: `http://127.0.0.1:${port}/v1`,
posts: () => state.requests.filter((r) => r.method === 'POST'),
otherWrites: () => state.requests.filter((r) => !['GET', 'POST'].includes(r.method)),
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
});
});
});
}
async function withFake(opts, fn) {
const fake = await createFakeAppwrite(opts);
try {
return await fn(fake);
} finally {
await fake.close();
}
}
async function run(fake, opts = {}) {
const lines = [];
const code = await main({
endpoint: fake.endpoint,
key: KEY,
project: 'aircard',
check: false,
log: (line) => lines.push(line),
pollMs: 1,
timeoutMs: 2000,
...opts,
});
return { code, lines };
}
const startsWith = (lines, prefix) => lines.filter((l) => l.startsWith(prefix));
function runCli(args, env) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [SCRIPT, ...args], {
env: { PATH: process.env.PATH, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('error', reject);
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
// ---------------------------------------------------------------------------
// Reine Helfer
// ---------------------------------------------------------------------------
describe('DESIRED', () => {
test('entspricht dem Schema aus der Spec', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.equal(DESIRED.table.id, 'cards');
assert.equal(DESIRED.table.name, 'Cards');
assert.deepEqual(DESIRED.table.permissions, ['create("users")']);
assert.equal(DESIRED.table.rowSecurity, true);
assert.deepEqual(
DESIRED.table.columns.map((c) => [c.key, c.size, c.required]),
[['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]],
);
assert.deepEqual(DESIRED.bucket, {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
});
});
test('Standard-Endpoint ist der Server von WEBklar', () => {
assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1');
});
});
describe('isMissing', () => {
test('404 mit anderem Typ gilt als fehlend', () => {
assert.equal(isMissing(404, { type: 'database_not_found' }), true);
assert.equal(isMissing(404, { type: 'table_not_found' }), true);
assert.equal(isMissing(404, { type: 'column_not_found' }), true);
assert.equal(isMissing(404, { message: 'kein JSON' }), true);
assert.equal(isMissing(404, undefined), true);
});
test('404 project_not_found ist NICHT "fehlt"', () => {
assert.equal(isMissing(404, { type: 'project_not_found' }), false);
});
test('alle anderen Status sind nicht "fehlt"', () => {
assert.equal(isMissing(200, {}), false);
assert.equal(isMissing(401, { type: 'user_unauthorized' }), false);
assert.equal(isMissing(403, {}), false);
assert.equal(isMissing(500, { type: 'general_server_error' }), false);
});
});
describe('compareTable', () => {
const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true });
test('passende Tabelle: keine Abweichung', () => {
assert.deepEqual(compareTable(ok(), DESIRED.table), []);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] };
const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] };
assert.deepEqual(compareTable(actual, desired), []);
});
test('abweichende oder zusätzliche Rechte werden gemeldet', () => {
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table);
assert.equal(wrong.length, 1);
assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table);
assert.equal(extra.length, 1);
assert.match(extra[0], /^\$permissions /);
});
test('rowSecurity aus wird gemeldet', () => {
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [
'rowSecurity ist false, erwartet true',
]);
});
test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2);
});
});
describe('compareColumn', () => {
const desired = DESIRED.table.columns[0]; // name, 80, required
const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(ok(), desired), []);
});
test('size, required und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']);
assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => {
const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired);
assert.equal(msg, 'size ist undefined, erwartet 80');
});
});
describe('compareBucket', () => {
const ok = () => ({
$id: 'card-images', name: 'Card Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], compression: 'none', antivirus: false,
});
test('passender Bucket: keine Abweichung', () => {
assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []);
});
test('Endungen werden sortiert verglichen', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []);
});
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
]);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1);
});
test('fileSecurity und maximumFileSize werden gemeldet', () => {
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [
'maximumFileSize ist 30000000, erwartet 5242880',
]);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => {
const desired = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] };
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1);
});
});
// ---------------------------------------------------------------------------
// Ablauf gegen den Fake-Server
// ---------------------------------------------------------------------------
describe('main gegen Fake-Server', () => {
const EXPECTED_OBJECTS = [
'Datenbank aircard',
'Tabelle cards',
'Spalte cards.name',
'Spalte cards.config',
'Spalte cards.imageFileId',
'Spalte cards.previewFileId',
'Bucket card-images',
];
test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0);
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
});
test('Anlegen, erneut prüfen, erneut anlegen (idempotent)', async () => {
await withFake({}, async (fake) => {
// Anlegen
const first = await run(fake);
assert.equal(first.code, 0);
assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.equal(startsWith(first.lines, 'FEHLT').length, 0);
assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0);
assert.ok(first.lines.includes('OK: Schema vollständig'));
// Bodies und Reihenfolge exakt wie im Brief
assert.deepEqual(
fake.posts().map((r) => [r.path, r.body]),
[
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
['/v1/tablesdb/aircard/tables', { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'name', size: 80, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'config', size: 2000, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'imageFileId', size: 36, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'previewFileId', size: 36, required: true }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
],
);
// Jede Anfrage trägt die drei Header
for (const r of fake.state.requests) {
assert.equal(r.headers['content-type'], 'application/json');
assert.equal(r.headers['x-appwrite-project'], 'aircard');
assert.equal(r.headers['x-appwrite-key'], KEY);
}
// Der Key taucht nie in der Ausgabe auf
assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten wurden vor dem Ende ausgewartet (Status available)
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
// Zweite Prüfung
const postsBefore = fake.posts().length;
const check = await run(fake, { check: true });
assert.equal(check.code, 0);
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), EXPECTED_OBJECTS.map((o) => `OK: ${o}`));
assert.ok(check.lines.includes('OK: Schema vollständig'));
// Zweites Anlegen legt nichts doppelt an
const second = await run(fake);
assert.equal(second.code, 0);
assert.equal(startsWith(second.lines, 'ANGELEGT').length, 0);
assert.ok(second.lines.includes('OK: Schema vollständig'));
assert.equal(fake.posts().length, postsBefore);
assert.equal(fake.otherWrites().length, 0);
});
});
test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.tables.get('aircard/cards').rowSecurity = false;
fake.state.tables.get('aircard/cards').$permissions = ['read("any")'];
fake.state.columns.get('aircard/cards/name').size = 100;
fake.state.columns.get('aircard/cards/config').required = false;
fake.state.buckets.get('card-images').maximumFileSize = 30000000;
fake.state.buckets.get('card-images').allowedFileExtensions = ['png', 'gif'];
fake.state.buckets.get('card-images').fileSecurity = false;
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
const dev = startsWith(lines, 'ABWEICHUNG');
assert.ok(dev.includes('ABWEICHUNG: cards.rowSecurity ist false, erwartet true'), dev.join('\n'));
assert.ok(dev.includes('ABWEICHUNG: cards.name.size ist 100, erwartet 80'), dev.join('\n'));
assert.ok(dev.includes('ABWEICHUNG: cards.config.required ist false, erwartet true'), dev.join('\n'));
assert.ok(dev.includes('ABWEICHUNG: card-images.maximumFileSize ist 30000000, erwartet 5242880'), dev.join('\n'));
assert.ok(dev.includes('ABWEICHUNG: card-images.fileSecurity ist false, erwartet true'), dev.join('\n'));
assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: card-images.allowedFileExtensions ist [gif, png]')), dev.join('\n'));
assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: cards.$permissions ist [read("any")]')), dev.join('\n'));
assert.equal(dev.length, 7);
assert.ok(!lines.includes('OK: Schema vollständig'));
}
// Nur lesende Anfragen seit der Manipulation
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => {
await withFake({ project: 'anderes-projekt' }, async (fake) => {
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 2);
assert.equal(startsWith(lines, 'FEHLT').length, 0);
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Projekt/);
assert.match(abort[0], /aircard/);
assert.match(abort[0], /docs\/appwrite-console\.md/);
assert.ok(!lines.join('\n').includes(KEY));
}
assert.equal(fake.posts().length, 0);
});
});
test('401 (falscher Key) bricht ab, ohne den Key auszugeben', async () => {
await withFake({ key: 'ein-ganz-anderer-key' }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /401/);
assert.match(abort[0], /API-Key/);
const all = lines.join('\n');
assert.ok(!all.includes(KEY));
assert.ok(!all.includes('ein-ganz-anderer-key'));
assert.equal(fake.posts().length, 0);
});
});
test('Steht der Key im Servertext, wird er in der Meldung geschwärzt', async () => {
const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard'
? { status: 401, type: 'user_unauthorized', message: `Ungültiger Key ${KEY} abgelehnt` }
: null);
await withFake({ inject }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const all = lines.join('\n');
assert.ok(!all.includes(KEY));
assert.match(all, /Ungültiger Key \*\*\* abgelehnt/);
});
});
test('5xx bricht ab', async () => {
const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard'
? { status: 503, type: 'general_service_unavailable', message: 'Service temporarily unavailable' }
: null);
await withFake({ inject }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /503/);
assert.equal(startsWith(lines, 'FEHLT').length, 0);
assert.equal(fake.posts().length, 0);
});
});
test('Spalte wird nicht verfügbar: Abbruch nach Wartezeit', async () => {
await withFake({ stuckColumns: true }, async (fake) => {
const { code, lines } = await run(fake, { timeoutMs: 60 });
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /cards\.name/);
assert.match(abort[0], /nicht .*verfügbar/);
// nach dem Abbruch wurde kein Bucket mehr angelegt
assert.equal(fake.state.buckets.size, 0);
});
});
test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => {
const probe = http.createServer();
await new Promise((r) => probe.listen(0, '127.0.0.1', r));
const { port } = probe.address();
await new Promise((r) => probe.close(r));
const lines = [];
const code = await main({
endpoint: `http://127.0.0.1:${port}/v1`, key: KEY, project: 'aircard', check: true,
log: (l) => lines.push(l), pollMs: 1, timeoutMs: 100,
});
assert.equal(code, 2);
assert.match(startsWith(lines, 'ABBRUCH')[0], /nicht erreichbar/);
assert.ok(!lines.join('\n').includes(KEY));
});
});
// ---------------------------------------------------------------------------
// Aufruf als Kommandozeile
// ---------------------------------------------------------------------------
describe('CLI', () => {
test('ohne AIRCARD_APPWRITE_API_KEY: deutsche Fehlermeldung, Exit 2', async () => {
const { code, stdout, stderr } = await runCli([], {});
assert.equal(code, 2);
assert.equal(stdout, '');
assert.match(stderr, /AIRCARD_APPWRITE_API_KEY/);
assert.match(stderr, /fehlt/);
});
test('unbekannte Option: Exit 2', async () => {
const { code, stderr } = await runCli(['--foo'], { AIRCARD_APPWRITE_API_KEY: KEY });
assert.equal(code, 2);
assert.match(stderr, /Unbekannte Option/);
assert.ok(!stderr.includes(KEY));
});
test('--check gegen Fake-Server über Umgebungsvariablen: Exit 1, Key nicht in der Ausgabe', async () => {
await withFake({}, async (fake) => {
const { code, stdout, stderr } = await runCli(['--check'], {
AIRCARD_APPWRITE_API_KEY: KEY,
AIRCARD_APPWRITE_ENDPOINT: fake.endpoint,
});
assert.equal(code, 1);
assert.match(stdout, /FEHLT: Datenbank aircard/);
assert.match(stdout, /FEHLT: Bucket card-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
});
});
test('Anlegen und danach --check über die Kommandozeile: Exit 0', async () => {
await withFake({}, async (fake) => {
const env = { AIRCARD_APPWRITE_API_KEY: KEY, AIRCARD_APPWRITE_ENDPOINT: fake.endpoint };
const apply = await runCli([], env);
assert.equal(apply.code, 0, apply.stdout + apply.stderr);
const check = await runCli(['--check'], env);
assert.equal(check.code, 0, check.stdout + check.stderr);
assert.match(check.stdout, /OK: Schema vollständig/);
});
});
});
+64
View File
@@ -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();
},
};
}
+307
View File
@@ -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;
}
}
+14
View File
@@ -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);
+310
View File
@@ -0,0 +1,310 @@
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('login bei bereits aktiver Sitzung (user_session_already_exists)', () => {
const existing = () => fail(401, 'user_session_already_exists', 'Creation of a session is prohibited when a session is active.');
it('löscht die aktuelle Sitzung, versucht es einmal neu und liefert den Nutzer', async () => {
const auth = await freshAuth();
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' });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
expect(mocks.createEmailPasswordSession).toHaveBeenLastCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
});
it('ignoriert 401 beim Löschen (Sitzung war schon weg) und versucht es trotzdem neu', async () => {
const auth = await freshAuth();
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' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
});
it('scheitert der zweite Versuch auch, fliegt dieser Fehler weiter (keine Endlosschleife)', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValue(existing());
mocks.deleteSession.mockResolvedValue({});
await expect(auth.login('a@b.de', 'geheim123')).rejects.toMatchObject({ type: 'user_session_already_exists' });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
expect(mocks.get).not.toHaveBeenCalled();
});
it('falsche Zugangsdaten im zweiten Versuch kommen als solche an, der alte Nutzer ist dann abgemeldet', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'old', email: 'old@b.de' });
await auth.getCurrentUser();
const listener = vi.fn();
auth.onAuthChange(listener);
const wrong = fail(401, 'user_invalid_credentials');
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockRejectedValueOnce(wrong);
mocks.deleteSession.mockResolvedValue({});
await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(wrong);
expect(auth.currentUserId()).toBeNull();
expect(listener).toHaveBeenCalledWith(null);
});
it('scheitert das Löschen anders (z. B. Netz), fliegt dieser Fehler weiter und es gibt keinen zweiten Versuch', async () => {
const auth = await freshAuth();
const error = new TypeError('Failed to fetch');
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing());
mocks.deleteSession.mockRejectedValue(error);
await expect(auth.login('a@b.de', 'geheim123')).rejects.toBe(error);
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(1);
});
it('andere Fehler beim ersten Versuch löschen nichts', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockRejectedValue(fail(401, 'user_invalid_credentials'));
await expect(auth.login('a@b.de', 'falsch123')).rejects.toMatchObject({ type: 'user_invalid_credentials' });
expect(mocks.deleteSession).not.toHaveBeenCalled();
});
it('register: das anschließende Login räumt eine vorhandene Sitzung ebenfalls ab', async () => {
const auth = await freshAuth();
mocks.create.mockResolvedValue({});
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' });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
});
});
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.');
});
});
+117
View File
@@ -0,0 +1,117 @@
// 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 { errorType, 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;
}
/** Beendet die Sitzung dieses Browsers auf dem Server; 401 heißt, es gab keine mehr. */
async function deleteCurrentSession(): Promise<void> {
try {
await account.deleteSession({ sessionId: 'current' });
} catch (err) {
if (!isUnauthorized(err)) throw err;
}
}
export async function login(email: string, password: string): Promise<User> {
try {
await account.createEmailPasswordSession({ email, password });
} catch (err) {
if (errorType(err) !== 'user_session_already_exists') throw err;
// Der Browser schickt noch eine gültige Sitzung mit (anderer Tab, fehlgeschlagene Startprüfung,
// halb fertiger Login). Appwrite lehnt ein Login dann ab; also die alte Sitzung beenden und genau einmal neu versuchen.
await deleteCurrentSession();
if (cachedUser) setUser(null); // die alte Sitzung ist weg, auch wenn der neue Versuch scheitert
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> {
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
await deleteCurrentSession();
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.`;
}
+166
View File
@@ -0,0 +1,166 @@
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_session_already_exists: Hinweis mit Handlungsanweisung', () => {
expect(errorMessage(fail(401, 'user_session_already_exists'), 'login')).toBe(
'Du bist schon angemeldet. Bitte lade die Seite neu.',
);
expect(errorMessage(fail(401, 'user_session_already_exists'), 'register')).toBe(
'Du bist schon angemeldet. Bitte lade die Seite neu.',
);
});
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);
});
});
+85
View File
@@ -0,0 +1,85 @@
// 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_session_already_exists':
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
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;
}
+279
View File
@@ -0,0 +1,279 @@
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('requireLogin: showModal schlägt fehl', () => {
it('räumt auf und wirft; der nächste Aufruf öffnet normal', () => {
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
throw new Error('showModal kaputt');
});
expect(() => requireLogin()).toThrow('showModal kaputt');
spy.mockRestore();
const again = requireLogin();
expect(dialogEl().open).toBe(true);
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
return expect(again).resolves.toBe(false);
});
});
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('bereits aktive Sitzung: wird ersetzt, der Dialog meldet erfolgreich an', async () => {
mocks.createEmailPasswordSession
.mockRejectedValueOnce(fail(401, 'user_session_already_exists'))
.mockResolvedValueOnce({});
mocks.deleteSession.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.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
});
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');
});
});
+314
View File
@@ -0,0 +1,314 @@
// 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);
try {
dialog.showModal();
} catch (err) {
// Sonst bliebe `pending` für alle späteren Aufrufe ein totes Promise.
finish(false);
throw err;
}
// 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
View File
@@ -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);
});
+153
View File
@@ -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();
});
});
+128
View File
@@ -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;
};
}
+11
View File
@@ -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;
}
+2
View File
@@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
// scripts/*.test.mjs laufen mit `node --test` (siehe web/scripts/appwrite-setup.test.mjs).
include: ['src/**/*.test.ts'],
},
});