Files
AirCardIOS/web/src/account/profile.ts
T

251 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import { isRowMissing } from './errors';
/** 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 --------------------------------------------------------------------------------------------------
/**
* 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
}
}