Einladen, statt sich für jemanden ein Passwort auszudenken
Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen und Passwort selbst und ist danach angemeldet. Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat, einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es versehentlich zu setzen. **Der Link steht genau einmal da.** Gespeichert ist nur sein Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem Reverse Proxy rät er falsch. **Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide „eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da — zwei gleichzeitige Einlösungen ergeben genau ein Konto. **Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem Einlösen kommt ein Bild mit 200 zurück. **Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie, wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite, ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten, damit ein nagelneuer Server zuerst seinen Administrator bekommt. Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt: abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der Gastgeber rät mit. Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit `NOCASE`. Das Namensrennen war also nie offen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c65356c16f
commit
d5e21d663f
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
einladungAnsehen, einladungEinloesen, anmelden, abmelden, sitzung, angemeldet,
|
||||
type Einladungsschau,
|
||||
} from '../api';
|
||||
import { knopf, feld, fehlerzeile, meldung, esc } from '../util';
|
||||
|
||||
/** Die Seite hinter einem Einladungslink.
|
||||
*
|
||||
* **Die einzige Stelle neben dem Einrichten, an der jemand ohne Anmeldung
|
||||
* etwas anlegt** — und sie verschliesst sich selbst: der Link gilt begrenzt
|
||||
* oft und begrenzt lange, und der Server prüft beides noch einmal.
|
||||
*
|
||||
* **Name und Passwort gibt sich der Eingeladene selbst.** Das ist der ganze
|
||||
* Sinn: ein Konto von Hand anzulegen hiesse, sich ein Passwort für einen
|
||||
* anderen auszudenken und es ihm über denselben Kanal durchzusagen, über den
|
||||
* er auch den Link bekommen hätte.
|
||||
*/
|
||||
export function einladungsseite(token: string, fertig: () => void): HTMLElement {
|
||||
const karte = document.createElement('div');
|
||||
karte.className = 'login-card';
|
||||
karte.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
// **Wer schon angemeldet ist, bekommt kein Formular.** Ein zweites Konto
|
||||
// für denselben Menschen ist fast nie gemeint; viel wahrscheinlicher ist
|
||||
// der Gastgeber, der seinen eigenen Link anklickt, um zu sehen, ob er geht.
|
||||
if (angemeldet()) {
|
||||
karte.innerHTML = '';
|
||||
const h = document.createElement('h1');
|
||||
h.textContent = 'Einladung';
|
||||
const satz = document.createElement('p');
|
||||
satz.className = 'hinweis';
|
||||
satz.innerHTML = `Du bist schon als <strong>${esc(sitzung.wer?.userName ?? '')}</strong> `
|
||||
+ 'angemeldet. Für ein neues Konto musst du dich zuerst abmelden.';
|
||||
|
||||
const weiter = knopf('Weiter zu Flaro', 'btn btn-primary');
|
||||
weiter.onclick = fertig;
|
||||
|
||||
const wechseln = knopf('Abmelden und Konto anlegen', 'btn');
|
||||
wechseln.onclick = () => { abmelden(); location.reload(); };
|
||||
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.append(weiter, wechseln);
|
||||
karte.append(h, satz, reihe);
|
||||
return karte;
|
||||
}
|
||||
|
||||
void laden();
|
||||
return karte;
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let schau: Einladungsschau;
|
||||
try {
|
||||
schau = await einladungAnsehen(token);
|
||||
} catch (fehler) {
|
||||
// **Der Grund steht da.** „Gilt nicht" wäre verschwiegener, aber wer
|
||||
// einen Link bekommen hat und abgewiesen wird, muss wissen, ob er zu
|
||||
// spät ist oder ob jemand ihn zurückgezogen hat — sonst fragt er nach,
|
||||
// und der Gastgeber rät mit.
|
||||
karte.innerHTML = '<h1>Einladung</h1>';
|
||||
karte.appendChild(fehlerzeile(meldung(fehler)));
|
||||
const zurueck = knopf('Zur Anmeldung', 'btn');
|
||||
zurueck.onclick = fertig;
|
||||
karte.appendChild(zurueck);
|
||||
return;
|
||||
}
|
||||
|
||||
karte.innerHTML = '';
|
||||
|
||||
const titel = document.createElement('h1');
|
||||
titel.textContent = `Willkommen bei ${schau.serverName}`;
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.className = 'hinweis';
|
||||
const bis = new Date(schau.expiresAt)
|
||||
.toLocaleDateString('de-CH', { dateStyle: 'medium' });
|
||||
text.innerHTML = `<strong>${esc(schau.invitedBy)}</strong> hat dich eingeladen. `
|
||||
+ `Wähle dir Namen und Passwort — der Link gilt noch bis zum ${esc(bis)}.`;
|
||||
|
||||
const name = feld('Benutzername');
|
||||
name.autocomplete = 'username';
|
||||
const passwort = feld('Passwort, mindestens 8 Zeichen', 'password');
|
||||
passwort.autocomplete = 'new-password';
|
||||
|
||||
const bleiben = document.createElement('label');
|
||||
bleiben.className = 'bleiben';
|
||||
const haken = document.createElement('input');
|
||||
haken.type = 'checkbox';
|
||||
haken.checked = true;
|
||||
bleiben.append(haken, document.createTextNode(' Angemeldet bleiben'));
|
||||
|
||||
const los = knopf('Konto anlegen', 'btn btn-primary');
|
||||
|
||||
karte.append(titel, text, name, passwort, bleiben, los);
|
||||
|
||||
async function absenden(): Promise<void> {
|
||||
karte.querySelector('.fehler')?.remove();
|
||||
|
||||
// Hier schon, damit der Link nicht für einen Tippfehler hinausgeht.
|
||||
if (name.value.trim().length === 0 || passwort.value.length < 8) {
|
||||
karte.appendChild(fehlerzeile(
|
||||
'Benutzername darf nicht leer sein, das Passwort braucht mindestens acht Zeichen.'));
|
||||
return;
|
||||
}
|
||||
|
||||
los.disabled = true;
|
||||
try {
|
||||
await einladungEinloesen(token, name.value.trim(), passwort.value);
|
||||
} catch (fehler) {
|
||||
karte.appendChild(fehlerzeile(meldung(fehler)));
|
||||
los.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// **Angelegt ist nicht angemeldet.** Scheitert dieser zweite Schritt,
|
||||
// steht das Konto trotzdem — dann ist der Weg die gewöhnliche
|
||||
// Anmeldung, und genau das muss dastehen.
|
||||
try {
|
||||
await anmelden(name.value.trim(), passwort.value, haken.checked);
|
||||
fertig();
|
||||
} catch {
|
||||
karte.appendChild(fehlerzeile(
|
||||
'Das Konto steht, aber die Anmeldung hat nicht geklappt. '
|
||||
+ 'Melde dich mit deinem neuen Namen und Passwort an.'));
|
||||
los.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
los.onclick = () => void absenden();
|
||||
name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); };
|
||||
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
|
||||
setTimeout(() => name.focus(), 50);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { benutzer, benutzerAnlegen } from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung } from '../../util';
|
||||
import {
|
||||
benutzer, benutzerAnlegen,
|
||||
einladungAnlegen, einladungen, einladungZurueckziehen, type Einladung,
|
||||
} from '../../api';
|
||||
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../../util';
|
||||
import { dialog } from '../../dialog';
|
||||
|
||||
import { abschnitt } from './teile';
|
||||
@@ -38,13 +41,130 @@ export function benutzerbereich(): HTMLElement {
|
||||
},
|
||||
});
|
||||
|
||||
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
|
||||
// **Einladen statt ein Konto erfinden.** Ein Konto von Hand anzulegen
|
||||
// heisst, sich für jemand anderen ein Passwort auszudenken und es ihm
|
||||
// durchzusagen — über denselben Kanal, über den auch der Link ginge.
|
||||
const einladen = knopf('Einladen…', 'btn');
|
||||
einladen.onclick = () => dialog({
|
||||
titel: 'Jemanden einladen',
|
||||
text: 'Wer den Link öffnet, wählt Benutzername und Passwort selbst und ist '
|
||||
+ 'danach angemeldet. Administrator wird dabei niemand.',
|
||||
felder: [
|
||||
{
|
||||
name: 'tage', beschriftung: 'Gültig',
|
||||
auswahl: [
|
||||
{ wert: '1', text: 'Einen Tag' },
|
||||
{ wert: '7', text: 'Eine Woche' },
|
||||
{ wert: '30', text: 'Einen Monat' },
|
||||
],
|
||||
wert: '7',
|
||||
},
|
||||
{
|
||||
name: 'wieoft', beschriftung: 'Benutzbar',
|
||||
auswahl: [
|
||||
{ wert: '1', text: 'Einmal' },
|
||||
{ wert: '5', text: 'Fünfmal' },
|
||||
{ wert: '', text: 'Unbegrenzt' },
|
||||
],
|
||||
wert: '1',
|
||||
hinweis: 'Ein mehrfacher Link ist bequem für eine Familie — und wer ihn '
|
||||
+ 'weitergibt, lädt jemanden ein, den du nicht gemeint hast.',
|
||||
},
|
||||
],
|
||||
bestaetigen: 'Link erstellen',
|
||||
tun: async wert => {
|
||||
const wieOft = wert('wieoft') === '' ? null : Number(wert('wieoft'));
|
||||
const neu = await einladungAnlegen(Number(wert('tage')), wieOft);
|
||||
await laden();
|
||||
linkZeigen(neu.token);
|
||||
},
|
||||
});
|
||||
|
||||
bereich.appendChild(abschnitt('Benutzer', [einladen, anlegen]));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
// **Bleibt weg, solange niemand eingeladen wurde.** Eine Überschrift über
|
||||
// einer leeren Liste erklärt nichts; den Weg dorthin zeigt der Knopf oben.
|
||||
const einladungskopf = abschnitt('Einladungen');
|
||||
einladungskopf.classList.add('hidden');
|
||||
const einladungsliste = document.createElement('div');
|
||||
einladungsliste.className = 'einstellungen hidden';
|
||||
bereich.append(einladungskopf, einladungsliste);
|
||||
|
||||
void laden();
|
||||
|
||||
/** Der fertige Link, genau einmal.
|
||||
*
|
||||
* **Die Adresse baut der Client.** Der Server weiss nicht, unter welchem
|
||||
* Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er
|
||||
* falsch, und ein falscher Einladungslink ist schlimmer als keiner. */
|
||||
function linkZeigen(token: string): void {
|
||||
const adresse = `${location.origin}${location.pathname}#/einladung/${token}`;
|
||||
|
||||
dialog({
|
||||
titel: 'Einladung fertig',
|
||||
text: 'Schick diesen Link weiter. Er steht nur jetzt da — gespeichert ist '
|
||||
+ 'nur sein Fingerabdruck, aus der Liste lässt er sich nicht noch '
|
||||
+ 'einmal herstellen.',
|
||||
felder: [{ name: 'link', beschriftung: 'Link', wert: adresse }],
|
||||
bestaetigen: 'Kopieren',
|
||||
tun: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(adresse);
|
||||
hinweisZeigen('Link kopiert.');
|
||||
} catch {
|
||||
// Ohne Zwischenablage — etwa ohne HTTPS — bleibt das Feld zum
|
||||
// Markieren stehen, und das ist kein Fehler wert.
|
||||
hinweisZeigen('Kopieren ging nicht. Der Link steht im Feld.', 'fehler');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Offene Einladungen ---------- */
|
||||
|
||||
function einladungszeile(e: Einladung): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const benutzt = e.maxUses === null
|
||||
? `${e.usedCount}× benutzt, unbegrenzt`
|
||||
: `${e.usedCount} von ${e.maxUses} benutzt`;
|
||||
const stand = e.revokedAt ? 'Zurückgezogen'
|
||||
: e.valid ? `Gültig bis ${datum(e.expiresAt)}`
|
||||
: 'Abgelaufen oder aufgebraucht';
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'zeile';
|
||||
zeile.style.flex = '1';
|
||||
zeile.innerHTML = `<strong>${esc(stand)}</strong>`
|
||||
+ `<span class="winzig">${esc(`${benutzt} · von ${e.createdBy} · ${datum(e.createdAt)}`)}</span>`;
|
||||
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.appendChild(zeile);
|
||||
|
||||
if (!e.revokedAt) {
|
||||
const weg = knopf('Zurückziehen', 'btn');
|
||||
weg.onclick = async () => {
|
||||
weg.disabled = true;
|
||||
try { await einladungZurueckziehen(e.id); await laden(); }
|
||||
catch (fehler) { weg.disabled = false; d.appendChild(fehlerzeile(meldung(fehler))); }
|
||||
};
|
||||
reihe.appendChild(weg);
|
||||
}
|
||||
|
||||
d.appendChild(reihe);
|
||||
return d;
|
||||
}
|
||||
|
||||
function datum(wert: string): string {
|
||||
return new Date(wert).toLocaleDateString('de-CH', { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
|
||||
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
|
||||
@@ -64,6 +184,14 @@ export function benutzerbereich(): HTMLElement {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
|
||||
try {
|
||||
const offene = await einladungen();
|
||||
einladungsliste.innerHTML = '';
|
||||
einladungskopf.classList.toggle('hidden', offene.length === 0);
|
||||
einladungsliste.classList.toggle('hidden', offene.length === 0);
|
||||
offene.forEach(e => einladungsliste.appendChild(einladungszeile(e)));
|
||||
} catch { /* Ein Abschnitt, der nicht lädt, fehlt eben. */ }
|
||||
}
|
||||
|
||||
return bereich;
|
||||
|
||||
Reference in New Issue
Block a user