Adminrecht geben und nehmen — und einen, dem es niemand nimmt

Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 18:31:05 +02:00
co-authored by Claude Opus 5
parent d5e21d663f
commit a7a4419626
12 changed files with 1576 additions and 15 deletions
+24
View File
@@ -5,6 +5,8 @@
export type Rolle = {
id: string; userName: string; isAdministrator: boolean;
/** Der Besitzer — ihm nimmt niemand das Adminrecht. */
isOwner?: boolean;
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */
language?: string | null;
/** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */
@@ -174,6 +176,8 @@ export type Systemauskunft = {
build: string;
serverName: string | null;
needsSetup: boolean; attributions: Hinweis[];
/** Eine der vier Stufen des Aufmachers: niedrig, mittel, hoch, sehrhoch. */
heroHeight?: string;
};
/* ---------- Sitzung ---------- */
@@ -403,6 +407,12 @@ export const bildAdresse = (schluessel: string | null): string | null =>
export const systemauskunft = () =>
anfrage<Systemauskunft>('/system/info');
/** Setzt die Hoehe des Aufmachers — serverweit, nur Admin. */
export const aufmacherhoeheSetzen = (stufe: string) =>
anfrage<void>('/system/hero-height', {
method: 'PUT', body: JSON.stringify({ height: stufe }),
});
export const einrichten = (benutzer: string, passwort: string) =>
anfrage<Rolle>('/system/setup', {
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
@@ -1081,6 +1091,20 @@ export const benutzerAnlegen = (name: string, passwort: string, admin: boolean)
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
});
/** Gibt oder nimmt das Adminrecht. Dem Besitzer nimmt es niemand. */
export const adminrechtSetzen = (id: string, admin: boolean) =>
anfrage<void>(`/users/${id}/administrator`, {
method: 'PUT', body: JSON.stringify({ isAdministrator: admin }),
});
/** Gibt den Besitz weiter — nur der Besitzer, nur an einen Administrator. */
export const besitzUebergeben = (id: string) =>
anfrage<void>(`/users/${id}/owner`, { method: 'POST' });
/** Loescht ein Konto samt allem, was daran haengt. */
export const benutzerLoeschen = (id: string) =>
anfrage<void>(`/users/${id}`, { method: 'DELETE' });
/* ---------- Einladungen ---------- */
export type Einladung = {
+73 -10
View File
@@ -1,9 +1,10 @@
import {
benutzer, benutzerAnlegen,
einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung,
benutzer, benutzerAnlegen, adminrechtSetzen, besitzUebergeben, benutzerLoeschen,
einladungAnlegen, einladungen, einladungZurueckziehen, sitzung,
type Einladung, type Rolle,
} from '../../api';
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
import { dialog } from '../../dialog';
import { dialog, nachfragen } from '../../dialog';
import { abschnitt } from './teile';
@@ -96,6 +97,74 @@ export function benutzerbereich(): HTMLElement {
void laden();
/** Ein Konto mit dem, was man daran tun darf.
*
* **Der Besitzer ist ausgenommen.** Sobald Administratoren einander das
* Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
* und aus dem führt kein Weg zurück ausser dem an der Datenbank. Darum
* trägt ein Konto diese Marke, und an ihm sind beide Knöpfe aus. */
function kontozeile(b: Rolle): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const ich = b.id === sitzung.wer?.id;
const rolle = b.isOwner ? 'Besitzer'
: b.isAdministrator ? 'Administrator' : 'Benutzer';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(b.userName)}${ich ? ' · das bist du' : ''}</strong>`
+ `<span class="winzig">${esc(rolle)}</span>`;
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.appendChild(text);
// **Dem Besitzer nimmt niemand etwas, und sich selbst auch nicht.** Wer
// sich im Eifer das eigene Recht nimmt, steht vor einer Verwaltung, die
// er nicht mehr öffnen kann — und das Zurücknehmen braucht genau die.
if (!b.isOwner && !(ich && b.isAdministrator)) {
const recht = knopf(b.isAdministrator ? 'Adminrecht nehmen' : 'Zum Administrator', 'btn');
recht.onclick = async () => {
recht.disabled = true;
try { await adminrechtSetzen(b.id, !b.isAdministrator); await laden(); }
catch (fehler) { recht.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); }
};
reihe.appendChild(recht);
}
// Weitergeben darf nur der Besitzer, und nur an einen Administrator.
if (sitzung.wer?.isOwner && !b.isOwner && b.isAdministrator) {
const uebergeben = knopf('Besitz übergeben', 'btn');
uebergeben.onclick = () => nachfragen({
titel: 'Besitz übergeben',
text: `„${b.userName}“ wird Besitzer dieses Servers. Dir nimmt dann `
+ 'niemand mehr das Adminrecht ab — aber er kann es dir nehmen, '
+ 'und zurückholen kannst du den Besitz nicht.',
bestaetigen: 'Übergeben',
tun: async () => { await besitzUebergeben(b.id); location.reload(); },
});
reihe.appendChild(uebergeben);
}
if (!b.isOwner && !ich) {
const weg = knopf('Löschen', 'btn btn-gefahr');
weg.onclick = () => nachfragen({
titel: `„${b.userName}“ löschen`,
text: 'Das Konto und alles, was daran hängt: Geräte, Sehstände, das '
+ 'Sehprotokoll, Meldungen und offene Einladungen. Das lässt sich '
+ 'nicht zurückholen.',
bestaetigen: 'Löschen',
tun: async () => { await benutzerLoeschen(b.id); await laden(); },
});
reihe.appendChild(weg);
}
d.appendChild(reihe);
return d;
}
/** Der fertige Link, genau einmal.
*
* **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem
@@ -173,13 +242,7 @@ export function benutzerbereich(): HTMLElement {
try {
const alle = await benutzer();
liste.innerHTML = '';
alle.forEach(b => {
const d = document.createElement('div');
d.className = 'einstellung';
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
liste.appendChild(d);
});
alle.forEach(b => liste.appendChild(kontozeile(b)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));