feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage

- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht
  sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor`
  gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die
  bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil,
  Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der
  Merker `aircard.usernamePrompted`. `IDS.profiles`.
- Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi
  prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am
  Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`.
- Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und
  Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto);
  danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und
  der Namensdialog fragt.
- Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername
  ändern“ bzw. „Benutzername wählen“.
- main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der
  Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist).
- Texte in `en.ts`, Stile in `account.css`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:22:15 +00:00
1 parent 19c8151c7e
commit 3e4bc21f00
14 files changed
+2660 -40

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal.
import { Query, type Models } from 'appwrite';
import { ID_PATTERN } from '../card/config';
import { apiPost, unavailableError } from '../community/api';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { IDS, tables } from './appwrite';
import { currentUserId } from './auth';
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
/**
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
*/
export const RESERVED_USERNAMES: readonly string[] = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
const RESERVED = new Set(RESERVED_USERNAMES);
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
const LOOKUP_MAX = 100;
type TableRow = Models.Row & Record<string, unknown>;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
export function usernameKey(name: string): string {
return name.toLowerCase();
}
/**
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
*/
export function validateUsername(name: string): string | null {
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
return null;
}
// ---- Namen merken und melden ------------------------------------------------------------------------------
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
const names = new Map<string, string>();
const listeners = new Set<(userId: string, username: string) => void>();
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
function announce(userId: string, username: string): void {
names.set(userId, username);
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
for (const fn of [...listeners]) {
try {
fn(userId, username);
} catch (err) {
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
}
}
}
/**
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
*/
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
export function myUsername(): string | null {
const userId = currentUserId();
return userId ? (names.get(userId) ?? null) : null;
}
// ---- Lesen --------------------------------------------------------------------------------------------------
/** „Es gibt kein Profil (oder keines für mich)“; eine fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
function isRowMissing(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const { code, type } = err as { code?: unknown; type?: unknown };
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/**
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
*/
export async function getUsername(userId: string): Promise<string | null> {
if (!ID_PATTERN.test(userId)) return null;
try {
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
return text(row.username) || null;
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/**
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
*/
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
const found = new Map<string, string>();
const missing: string[] = [];
for (const id of new Set(userIds)) {
if (!ID_PATTERN.test(id)) continue;
const known = names.get(id);
if (known === undefined) missing.push(id);
else found.set(id, known);
}
try {
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
const ids = missing.slice(from, from + LOOKUP_MAX);
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
});
for (const row of list.rows) {
const name = text(row.username);
if (name === '') continue;
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
const current = names.get(row.$id) ?? name;
names.set(row.$id, current);
found.set(row.$id, current);
}
}
} catch {
// nur Beiwerk der Anzeige, siehe oben
}
return found;
}
/**
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
* Fehler (Netz, Server) gehen durch.
*/
export async function isUsernameTaken(name: string): Promise<boolean> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
});
const own = currentUserId();
return list.rows.some((row) => row.$id !== own);
}
/**
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
* Name“, und nur bei „kein Name“ wird gefragt.
*/
export async function loadMyUsername(): Promise<string | null> {
const userId = currentUserId();
if (!userId) return null;
const name = await getUsername(userId);
// Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name.
if (name === null) return names.get(userId) ?? null;
announce(userId, name);
return name;
}
// ---- Speichern ----------------------------------------------------------------------------------------------
/**
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
*/
export async function saveUsername(name: string): Promise<string> {
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
const res = await apiPost('/api/profil', { username: name }, { auth: true });
const saved = res.username;
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
if (typeof saved !== 'string' || saved === '') throw unavailableError();
if (userId) announce(userId, saved);
return saved;
}
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
const PROMPTED_KEY = 'aircard.usernamePrompted';
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
let promptedInMemory = false;
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
export function usernamePrompted(): boolean {
if (promptedInMemory) return true;
try {
return sessionStorage.getItem(PROMPTED_KEY) === '1';
} catch {
return false;
}
}
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
export function markUsernamePrompted(): void {
promptedInMemory = true;
try {
sessionStorage.setItem(PROMPTED_KEY, '1');
} catch {
// gesperrt oder voll: es bleibt beim Merker im Speicher
}
}
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
export function clearUsernamePrompted(): void {
promptedInMemory = false;
try {
sessionStorage.removeItem(PROMPTED_KEY);
} catch {
// gesperrt: nichts zu entfernen
}
}