Compare commits
3
Commits
906bdf1033
...
a147d3b496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a147d3b496 | ||
|
|
10c55ca933 | ||
|
|
d1d4d621a7 |
No files matched your search
@@ -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.
|
||||
Generated
+29
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { currentUser, logout, onAuthChange } from './auth';
|
||||
import { errorMessage } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
export interface AccountMenu {
|
||||
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
||||
ready(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
let isReady = false;
|
||||
|
||||
function render(): void {
|
||||
if (!isReady) {
|
||||
slot.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const user = currentUser();
|
||||
if (!user) {
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
|
||||
signIn.addEventListener('click', () => void requireLogin('login'));
|
||||
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
||||
return;
|
||||
}
|
||||
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
||||
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
|
||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||
signOut.addEventListener('click', () => {
|
||||
signOut.disabled = true;
|
||||
error.textContent = '';
|
||||
// Bei Erfolg zeichnet onAuthChange das Menü neu; nur der Fehlerfall braucht Handarbeit.
|
||||
logout().catch((err: unknown) => {
|
||||
error.textContent = errorMessage(err, 'load');
|
||||
signOut.disabled = false;
|
||||
});
|
||||
});
|
||||
slot.replaceChildren(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
|
||||
el('div', { class: 'account-row' }, email, signOut),
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const off = onAuthChange(render);
|
||||
render();
|
||||
|
||||
return {
|
||||
ready() {
|
||||
isReady = true;
|
||||
render();
|
||||
},
|
||||
dispose() {
|
||||
off();
|
||||
slot.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/* Konto: Login-Dialog, Konto-Menü im Kopf, Seite „Passwort neu setzen“. */
|
||||
|
||||
/* Knöpfe (eigene Klassen, damit das Konto-Modul nicht am Editor-Stylesheet hängt) */
|
||||
|
||||
.auth-btn {
|
||||
appearance: none;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.auth-btn:hover:not(:disabled) {
|
||||
border-color: var(--sel-line);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.auth-btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-btn-primary:hover:not(:disabled) {
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.auth-btn-small {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--sel-line);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-link:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Konto-Menü im Seitenkopf */
|
||||
|
||||
#account-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
min-width: 0;
|
||||
max-width: 28ch;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-error {
|
||||
color: #ff9a9d;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Login-Dialog */
|
||||
|
||||
.auth-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
.auth-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.auth-close {
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-close:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
margin: 0 40px 16px 0;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-tab[aria-selected='true'] {
|
||||
background: var(--sel-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-tab:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Formulare (Dialog und Passwort-neu-Seite) */
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
|
||||
}
|
||||
|
||||
.auth-input:focus-visible {
|
||||
outline: 2px solid var(--sel-line);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.auth-input[aria-invalid='true'] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.auth-input[readonly] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: -6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-msg {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-msg:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-msg-error {
|
||||
color: #ff9a9d;
|
||||
}
|
||||
|
||||
.auth-msg-ok {
|
||||
color: #8fd9a8;
|
||||
}
|
||||
|
||||
.auth-aside {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Seite /passwort-neu */
|
||||
|
||||
.reset-page {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.reset-page .auth-msg {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.reset-actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
|
||||
|
||||
@media (max-width: 599px) {
|
||||
#app-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
#account-slot {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Appwrite-Client der Seite: ein Client, die Dienste daraus.
|
||||
// Im Browser läuft die API über dieselbe Domain (`/v1`), damit das Session-Cookie first-party ist.
|
||||
import { Account, Client, Storage, TablesDB } from 'appwrite';
|
||||
|
||||
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
|
||||
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || 'aircard';
|
||||
|
||||
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
|
||||
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
|
||||
|
||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||
export const account: Account = new Account(client);
|
||||
export const tables: TablesDB = new TablesDB(client);
|
||||
export const storage: Storage = new Storage(client);
|
||||
@@ -0,0 +1,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.');
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,24 +1,37 @@
|
||||
import '@fontsource-variable/nunito-sans';
|
||||
import './styles.css';
|
||||
import { startRouter, type RouteId } from './pages/router';
|
||||
import { mountAccountMenu } from './account/account-menu';
|
||||
import { getCurrentUser } from './account/auth';
|
||||
import { startRouter } from './pages/router';
|
||||
import { mountResetPage } from './pages/reset-page';
|
||||
import { mountEditor } from './editor/editor-page';
|
||||
import { createStore, initialEditorState } from './editor/state';
|
||||
|
||||
// Platzhalter, bis die Seiten aus den Folgeaufgaben (Karten, Passwort) angebunden sind.
|
||||
const PLACEHOLDER_TITLES: Record<Exclude<RouteId, 'editor'>, string> = {
|
||||
cards: 'Meine Karten',
|
||||
reset: 'Passwort neu setzen',
|
||||
};
|
||||
|
||||
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
|
||||
const editorStore = createStore(initialEditorState());
|
||||
|
||||
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
|
||||
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
|
||||
const accountSlot = document.getElementById('account-slot');
|
||||
if (accountSlot) {
|
||||
const menu = mountAccountMenu(accountSlot);
|
||||
getCurrentUser()
|
||||
.catch((error: unknown) => {
|
||||
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
||||
})
|
||||
.finally(() => menu.ready());
|
||||
}
|
||||
|
||||
startRouter((route, root) => {
|
||||
if (route === 'editor') {
|
||||
// Speichern wird mit den Konten (spätere Aufgabe) angebunden; bis dahin passiert nichts.
|
||||
return mountEditor(root, editorStore, { onSave: () => {} });
|
||||
}
|
||||
if (route === 'reset') {
|
||||
return mountResetPage(root);
|
||||
}
|
||||
// Platzhalter, bis die Karten-Seite aus der Folgeaufgabe angebunden ist.
|
||||
const heading = document.createElement('h1');
|
||||
heading.textContent = PLACEHOLDER_TITLES[route];
|
||||
heading.textContent = 'Meine Karten';
|
||||
root.replaceChildren(heading);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
createEmailPasswordSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: mocks }));
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
|
||||
type Page = typeof import('./reset-page');
|
||||
let mountResetPage: Page['mountResetPage'];
|
||||
let root: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.resetModules();
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
root = document.getElementById('app-root')!;
|
||||
history.replaceState(null, '', '/passwort-neu');
|
||||
({ mountResetPage } = await import('./reset-page'));
|
||||
});
|
||||
|
||||
const withLink = () => history.replaceState(null, '', '/passwort-neu?userId=u1&secret=s3cret');
|
||||
const input = (name: string) => root.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const alertText = () => root.querySelector('[role="alert"]')!.textContent;
|
||||
const submit = () => root.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const fill = (password: string, repeat = password) => {
|
||||
input('password').value = password;
|
||||
input('password-repeat').value = repeat;
|
||||
};
|
||||
const button = (text: string) => [...root.querySelectorAll('button')].find((b) => b.textContent === text)!;
|
||||
|
||||
describe('ohne gültigen Link', () => {
|
||||
it('ohne Parameter: Hinweis und Knopf „Neuen Link anfordern“, der den Dialog öffnet', () => {
|
||||
mountResetPage(root);
|
||||
expect(root.querySelector('h1')!.textContent).toBe('Passwort neu setzen');
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
expect(root.querySelector('form')).toBeNull();
|
||||
button('Neuen Link anfordern').click();
|
||||
const dialog = document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.querySelector('h2')!.textContent).toBe('Passwort vergessen');
|
||||
});
|
||||
|
||||
it('mit nur einem der beiden Parameter ebenso', () => {
|
||||
history.replaceState(null, '', '/passwort-neu?userId=u1');
|
||||
mountResetPage(root);
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mit Link', () => {
|
||||
it('zeigt zwei Passwortfelder mit new-password', () => {
|
||||
withLink();
|
||||
mountResetPage(root);
|
||||
expect(input('password').autocomplete).toBe('new-password');
|
||||
expect(input('password-repeat').autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
it('prüft vor dem Senden', () => {
|
||||
withLink();
|
||||
mountResetPage(root);
|
||||
fill('geheim123', 'geheim124');
|
||||
submit();
|
||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||
fill('kurz');
|
||||
submit();
|
||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(input('password').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(mocks.updateRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setzt das Passwort, zeigt den Erfolg, entfernt das Geheimnis aus der Adresse und bietet „Anmelden“', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockResolvedValue({});
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() =>
|
||||
expect(root.querySelector('[role="status"]')!.textContent).toBe('Passwort geändert. Du kannst dich jetzt anmelden.'),
|
||||
);
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledWith({ userId: 'u1', secret: 's3cret', password: 'neuesPasswort1' });
|
||||
expect(location.search).toBe('');
|
||||
button('Anmelden').click();
|
||||
const dialog = document.querySelector<HTMLDialogElement>('dialog.auth-dialog')!;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.querySelector('h2')!.textContent).toBe('Anmelden');
|
||||
});
|
||||
|
||||
it('sperrt das Formular, solange die Anfrage läuft', async () => {
|
||||
withLink();
|
||||
let release!: () => void;
|
||||
mocks.updateRecovery.mockReturnValue(new Promise<void>((resolve) => (release = resolve)));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
submit();
|
||||
expect(mocks.updateRecovery).toHaveBeenCalledTimes(1);
|
||||
expect(root.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
release();
|
||||
await vi.waitFor(() => expect(root.querySelector('[role="status"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user_invalid_token', 401, ''],
|
||||
['user_not_found', 404, ''],
|
||||
['general_argument_invalid', 400, 'Invalid `userId` param: Parameter must be a valid number'],
|
||||
['general_argument_invalid', 400, 'Invalid `secret` param: Value must be a valid string'],
|
||||
])('Link-Fehler %s (%s) zeigt „Link ungültig“ und den Knopf „Neuen Link anfordern“', async (type, code, message) => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(fail(code, type, message));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(root.querySelector('form')).toBeNull());
|
||||
expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.');
|
||||
expect(button('Neuen Link anfordern')).toBeDefined();
|
||||
});
|
||||
|
||||
it('ein Passwortfehler vom Server steht am Feld, das Formular bleibt', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(
|
||||
fail(
|
||||
400,
|
||||
'general_argument_invalid',
|
||||
'Invalid `password` param: Password must be at least 8 characters and should not be one of the commonly used password.',
|
||||
),
|
||||
);
|
||||
mountResetPage(root);
|
||||
fill('password123');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.'));
|
||||
expect(root.querySelector('form')).not.toBeNull();
|
||||
expect(input('password').getAttribute('aria-invalid')).toBe('true');
|
||||
expect(root.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Netzfehler: Meldung, Formular bleibt', async () => {
|
||||
withLink();
|
||||
mocks.updateRecovery.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
mountResetPage(root);
|
||||
fill('neuesPasswort1');
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
|
||||
expect(root.querySelector('form')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Seite /passwort-neu: neues Passwort setzen mit dem Link aus der Mail (?userId=…&secret=…).
|
||||
import '../account/account.css';
|
||||
import { completePasswordReset, validatePassword } from '../account/auth';
|
||||
import { errorMessage, isInvalidLinkError } from '../account/errors';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { navigate } from './router';
|
||||
|
||||
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
|
||||
|
||||
export function mountResetPage(root: HTMLElement): () => void {
|
||||
let disposed = false;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const userId = params.get('userId') ?? '';
|
||||
const secret = params.get('secret') ?? '';
|
||||
|
||||
const heading = el('h1', {}, 'Passwort neu setzen');
|
||||
const page = el('section', { class: 'reset-page' });
|
||||
root.replaceChildren(heading, page);
|
||||
|
||||
function showInvalidLink(): void {
|
||||
const again = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Neuen Link anfordern');
|
||||
again.addEventListener('click', () => void requireLogin('forgot'));
|
||||
page.replaceChildren(
|
||||
el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }, 'Der Link ist ungültig oder abgelaufen.'),
|
||||
el('p', { class: 'reset-actions' }, again),
|
||||
);
|
||||
}
|
||||
|
||||
function showDone(): void {
|
||||
// Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben.
|
||||
history.replaceState(null, '', location.pathname);
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Anmelden');
|
||||
signIn.addEventListener('click', () => {
|
||||
void requireLogin('login').then((ok) => {
|
||||
if (ok && !disposed) navigate('/');
|
||||
});
|
||||
});
|
||||
page.replaceChildren(
|
||||
el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }, 'Passwort geändert. Du kannst dich jetzt anmelden.'),
|
||||
el('p', { class: 'reset-actions' }, signIn),
|
||||
);
|
||||
}
|
||||
|
||||
function showForm(): void {
|
||||
const password = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
autocomplete: 'new-password',
|
||||
required: true,
|
||||
'aria-describedby': 'reset-hint reset-error',
|
||||
});
|
||||
const repeat = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'password',
|
||||
name: 'password-repeat',
|
||||
autocomplete: 'new-password',
|
||||
required: true,
|
||||
'aria-describedby': 'reset-error',
|
||||
});
|
||||
const hint = el('p', { class: 'auth-hint', id: 'reset-hint' }, 'Mindestens 8 Zeichen.');
|
||||
const error = el('p', { class: 'auth-msg auth-msg-error', id: 'reset-error', role: 'alert' });
|
||||
const submit = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Passwort speichern');
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'auth-form' },
|
||||
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort'), password),
|
||||
hint,
|
||||
el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, 'Neues Passwort wiederholen'), repeat),
|
||||
error,
|
||||
submit,
|
||||
);
|
||||
|
||||
let busy = false;
|
||||
const setBusy = (value: boolean) => {
|
||||
busy = value;
|
||||
submit.disabled = value;
|
||||
password.readOnly = value;
|
||||
repeat.readOnly = value;
|
||||
form.setAttribute('aria-busy', String(value));
|
||||
};
|
||||
const fail = (text: string, field: HTMLInputElement) => {
|
||||
error.textContent = text;
|
||||
password.setAttribute('aria-invalid', String(field === password));
|
||||
repeat.setAttribute('aria-invalid', String(field === repeat));
|
||||
field.focus();
|
||||
};
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
error.textContent = '';
|
||||
password.removeAttribute('aria-invalid');
|
||||
repeat.removeAttribute('aria-invalid');
|
||||
const problem = password.value !== repeat.value ? MISMATCH : validatePassword(password.value);
|
||||
if (problem) {
|
||||
fail(problem, problem === MISMATCH ? repeat : password);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
completePasswordReset(userId, secret, password.value).then(
|
||||
() => {
|
||||
if (!disposed) showDone();
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (disposed) return;
|
||||
// Link-Fehler ersetzen das Formular; Passwort-, Netz- und sonstige Fehler stehen am Feld.
|
||||
if (isInvalidLinkError(err)) {
|
||||
showInvalidLink();
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
fail(errorMessage(err, 'reset'), password);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
page.replaceChildren(form);
|
||||
}
|
||||
|
||||
if (userId && secret) showForm();
|
||||
else showInvalidLink();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}
|
||||
Vendored
+11
@@ -1 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** Appwrite-Endpoint; ohne Angabe `${location.origin}/v1` (Produktion: https://aircard-ios.org/v1). */
|
||||
readonly VITE_APPWRITE_ENDPOINT?: string;
|
||||
/** Appwrite-Projekt-ID; ohne Angabe `aircard`. */
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user