From af77b9e809c7e8af07d958ed5f38a8e9bdaf07bb Mon Sep 17 00:00:00 2001 From: Sandro Date: Sat, 31 Oct 2026 13:43:54 +0100 Subject: [PATCH] feat(auth): invite links for several people, invite by email, registration with approval or email domains - Invites: link /?invite=CODE, copy link, send by email, max uses with counter - Registration modes closed | approval | domain | domain+approval | open, waiting list with approve/reject - REGISTRATION_MODE / REGISTRATION_DOMAINS lock the admin setting; ALLOW_REGISTRATION=true is honoured Co-Authored-By: Claude Opus 5.5 --- .env.example | 12 +- CHANGELOG.md | 14 +- Doku/api/rest.md | 8 +- Doku/architecture.md | 3 +- Doku/plans/registrierung-und-login.md | 2 +- README.md | 8 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/features/auth/SetupAndAuthView.tsx | 40 +- .../features/settings/AdminSettingsView.tsx | 346 ++++++++++++++---- frontend/src/lib/api.ts | 16 +- frontend/src/lib/types.ts | 14 + internal/api/handlers_admin.go | 33 +- internal/api/handlers_auth.go | 59 ++- internal/api/register_limits_test.go | 4 +- internal/api/registration.go | 209 +++++++++++ internal/api/registration_test.go | 157 ++++++++ internal/api/server.go | 2 + internal/config/config.go | 29 +- internal/domain/models.go | 26 +- internal/storage/migrations.go | 1 + internal/storage/registration.go | 127 +++++++ internal/storage/system.go | 28 +- internal/storage/users.go | 75 ++-- 24 files changed, 1077 insertions(+), 142 deletions(-) create mode 100644 internal/api/registration.go create mode 100644 internal/api/registration_test.go create mode 100644 internal/storage/registration.go diff --git a/.env.example b/.env.example index eabb67b..ebc9827 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,16 @@ BASE_URL=http://localhost:8080 # Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin). TZ=Europe/Berlin -# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin. -# Lässt sich später in Administration → Server & Isolation ändern. +# Registrierung ohne Einladung. Ohne Angabe entscheidet Administration → Server & Isolation. +# Gesetzt ist es dort gesperrt: +# closed nur mit Einladung +# approval jeder darf sich registrieren, ein Admin schaltet frei +# domain nur E-Mail-Adressen aus REGISTRATION_DOMAINS, sofort aktiv +# domain+approval diese Endungen sofort, alle anderen nach Freigabe +# open jeder, sofort aktiv +# REGISTRATION_MODE=approval +# REGISTRATION_DOMAINS=firma.de, familie.de +# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin. ALLOW_REGISTRATION=false # ─── Sicherheit ──────────────────────────────────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba724f..f81f697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.23.0] - 2026-10-31 + +### Added +- **Einladungslinks:** Jeder Einladungscode ist auch ein Link (`/?invite=CODE`), der die Registrierung mit eingetragenem Code öffnet. In der Administration gibt es dafür „Link kopieren“ und – mit Mail-Server – „Per E-Mail“ (Einladung im MindSift-Layout mit Knopf „Konto anlegen“; einmalige Codes werden dabei an die Adresse gebunden). +- **Ein Link für mehrere Personen:** Beim Erzeugen „Wie oft verwendbar“ angeben (z. B. 20); die Liste zeigt „3 von 20 verwendet“. Auch gleichzeitige Registrierungen können einen Link nie öfter einlösen als erlaubt. +- **Registrierung mit Freigabe und nach E-Mail-Endung:** Statt „offen/geschlossen“ gibt es fünf Modi – geschlossen, mit Freigabe durch einen Admin, nur bestimmte E-Mail-Endungen, Endungen sofort und alle anderen nach Freigabe, offen. Wartende Konten stehen in der Administration unter „Wartet auf Freigabe“ (Freigeben/Ablehnen); Admins bekommen eine E-Mail, die Person nach der Freigabe ebenfalls. Bis dahin ist keine Anmeldung möglich (mit Hinweis). Das Registrierungsformular erklärt, welche Adressen erlaubt sind. +- Neue Umgebungsvariablen `REGISTRATION_MODE` und `REGISTRATION_DOMAINS`: Gesetzt gelten sie und sind in der Administration gesperrt. `ALLOW_REGISTRATION=true` entspricht `REGISTRATION_MODE=open`. + +### Fixed +- `ALLOW_REGISTRATION` wurde bisher gar nicht ausgewertet; die Registrierung ließ sich in der Administration unabhängig davon ein- und ausschalten. + ## [0.22.2] - 2026-10-31 ### Fixed @@ -473,7 +484,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...dev +[0.23.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...v0.23.0 [0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2 [0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1 [0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index f346d49..dbefd8d 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -11,7 +11,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | -| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten | +| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); mit Code immer, sonst je nach Registrierungsmodus. `201` mit Token oder `202 {"pending":true,"message"}`, wenn ein Admin erst freigeben muss (Login bis dahin `403`); `429` nach 20 Versuchen pro Adresse in 15 Minuten oder bei mehr als 50 wartenden Konten | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | | POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten | | POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link | @@ -211,9 +211,11 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | PUT | `/admin/users/{id}/organization` | Organisation setzen (`organizationId`, leer = keine); entzieht Freigaben, Gruppen und Zuweisungen der alten | | GET/POST | `/admin/organizations` | Organisationen mit `memberCount`; anlegen (`name`) | | PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) | -| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen | +| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen (bei wartenden Konten = ablehnen). `GET /admin/users` markiert wartende Konten mit `pendingApproval` | +| POST | `/admin/users/{id}/approve` | Wartendes Konto freigeben; die Person bekommt eine E-Mail (`409`, wenn es nicht wartet) | | PUT | `/admin/users/{id}/password` | Passwort zurücksetzen | -| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) | +| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`, `maxUses` 1–1000; mehrfach verwendbare nicht an eine E-Mail gebunden). Antwort mit `maxUses`/`useCount`. Als Link: `/?invite=` öffnet die Registrierung mit eingetragenem Code | +| POST | `/admin/invites/{id}/send` | Einladung per E-Mail (`email`); bindet einmalige Codes an diese Adresse; braucht SMTP | | DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen | | GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert | | GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) | diff --git a/Doku/architecture.md b/Doku/architecture.md index 11fac48..2319998 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -63,7 +63,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus | | `BASE_URL` | `http://localhost:` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) | -| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | +| `REGISTRATION_MODE`, `REGISTRATION_DOMAINS` | leer | Registrierung ohne Einladung (`closed`, `approval`, `domain`, `domain+approval`, `open`) und erlaubte E-Mail-Endungen. Gesetzt gewinnen sie über `system_settings` und sind im Admin-UI gesperrt (`registrationPolicy` in `internal/api/registration.go`). Wartende Konten: `is_active = 0` plus Zeile in `pending_registrations` (Migration 10) | +| `ALLOW_REGISTRATION` | `false` | `true` = `REGISTRATION_MODE=open`; `false` (Standard der Vorlagen) lässt die Wahl dem Admin-UI | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) | | `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. | diff --git a/Doku/plans/registrierung-und-login.md b/Doku/plans/registrierung-und-login.md index 83f8199..2c0d73c 100644 --- a/Doku/plans/registrierung-und-login.md +++ b/Doku/plans/registrierung-und-login.md @@ -1,6 +1,6 @@ # Plan: Registrierung und Anmeldung (Entwurf) -Stand 2026-10-31, noch nicht umgesetzt. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. +Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). Offen: E-Mail-Bestätigung per Link, OpenID Connect. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung. ## Heute diff --git a/README.md b/README.md index 26cdd2e..c5877dc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.22.2-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.23.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -106,7 +106,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md) ### 🏢 Benutzer, Gruppen, Administration -* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung. +* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*. Einladungen als Code oder Link (auch für mehrere Personen, auf Wunsch per E-Mail verschickt). Registrierung ohne Einladung wahlweise geschlossen, mit Freigabe durch einen Admin, nur für bestimmte E-Mail-Endungen oder offen – auch fest per Umgebungsvariable. * Gruppen mit Gruppen-Admins für Freigaben. * Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung. @@ -191,7 +191,9 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) | | `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | -| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | +| `REGISTRATION_MODE` | leer (Admin-UI entscheidet) | Registrierung ohne Einladung: `closed`, `approval` (Admin gibt frei), `domain` (nur erlaubte E-Mail-Endungen), `domain+approval` (Endungen sofort, andere nach Freigabe), `open`. Gesetzt ist es im Admin-UI gesperrt | +| `REGISTRATION_DOMAINS` | leer | Erlaubte E-Mail-Endungen für die `domain`-Modi, z. B. `firma.de, *.firma.de`; gesetzt im Admin-UI gesperrt | +| `ALLOW_REGISTRATION` | `false` | `true` = wie `REGISTRATION_MODE=open` (gesperrt); `false` lässt die Wahl dem Admin-UI | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3970401..d326fdf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.22.2", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.22.2", + "version": "0.23.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index a6389c1..d5f1070 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.22.2", + "version": "0.23.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 12eecfd..a74b58c 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Brain, Shield, @@ -43,13 +43,37 @@ export const SetupAndAuthView: React.FC = ({ const [createSampleData, setCreateSampleData] = useState(false); // --- Login / Invite / Register State --- + // An invite link (?invite=CODE) opens the invite form with the code filled in + const [linkInviteCode] = useState(() => new URLSearchParams(window.location.search).get('invite')?.trim().toUpperCase() || ''); const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>( - resetToken ? 'reset' : 'login' + resetToken ? 'reset' : linkInviteCode ? 'invite' : 'login' ); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [displayName, setDisplayName] = useState(''); - const [inviteCode, setInviteCode] = useState(''); + const [inviteCode, setInviteCode] = useState(linkInviteCode); + // The code is in the form now; it should not stay in the address bar (history, screenshots) + useEffect(() => { + if (!linkInviteCode) return; + const url = new URL(window.location.href); + url.searchParams.delete('invite'); + window.history.replaceState({}, '', url.pathname + url.search + url.hash); + }, [linkInviteCode]); + // Hint on the sign-up form: which addresses get in, and whether an admin approves first + const registrationHint = (() => { + const domains = (authStatus.registrationDomains || []).map((d) => '@' + d.replace(/^\*\./, '')).join(', '); + switch (authStatus.registrationMode) { + case 'approval': + return 'Nach der Registrierung gibt ein Administrator dein Konto frei.'; + case 'domain': + return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : ''; + case 'domain+approval': + return domains + ? `Mit Adressen von ${domains} geht es sofort, andere Konten gibt ein Administrator frei.` + : 'Nach der Registrierung gibt ein Administrator dein Konto frei.'; + } + return ''; + })(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -167,6 +191,13 @@ export const SetupAndAuthView: React.FC = ({ try { const code = authTab === 'invite' ? inviteCode.trim() : ''; const res = await api.register(email.trim(), password, displayName.trim(), code); + if (res.pending || !res.user) { + // Waiting for approval: back to the login form with the explanation + setNotice(res.message || 'Dein Konto wartet auf die Freigabe durch einen Administrator.'); + setPassword(''); + setAuthTab('login'); + return; + } onAuthenticated(res.user); } catch (err: any) { setError(err.message || 'Registrierung fehlgeschlagen'); @@ -687,6 +718,9 @@ export const SetupAndAuthView: React.FC = ({ {(authTab === 'invite' || authTab === 'register') && (
+ {authTab === 'register' && registrationHint && ( +

{registrationHint}

+ )} {authTab === 'invite' && (
diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index cf7d144..bf6d066 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -15,9 +15,13 @@ import { UserPlus, Building2, Pencil, + Link2, + Clock, + X, } from 'lucide-react'; import { Organization, + RegistrationMode, SharingMode, SystemSettings, User, @@ -78,6 +82,12 @@ export const AdminSettingsView: React.FC = ({ const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState('member'); const [inviteOrg, setInviteOrg] = useState(''); + // A link for several people (1 = classic single-use code) + const [inviteMaxUses, setInviteMaxUses] = useState(1); + // "Per E-Mail einladen": which invite the address field is open for + const [mailInviteId, setMailInviteId] = useState(null); + const [mailInviteTo, setMailInviteTo] = useState(''); + const [mailInviteBusy, setMailInviteBusy] = useState(false); // --- Organizations State --- const [organizations, setOrganizations] = useState([]); @@ -128,6 +138,9 @@ export const AdminSettingsView: React.FC = ({ setSettings({ sharingMode: sys.sharingMode || 'organizations', allowRegistration: Boolean(sys.allowRegistration), + registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'), + registrationDomains: sys.registrationDomains || '', + registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv), instanceName: sys.instanceName || 'MindSift', smtpHost: sys.smtpHost || '', smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587', @@ -197,15 +210,19 @@ export const AdminSettingsView: React.FC = ({ triggerHaptic(15); try { const inv = await api.adminCreateInvite({ - email: inviteEmail.trim(), + email: inviteMaxUses > 1 ? '' : inviteEmail.trim(), role: inviteRole, organizationId: inviteOrg || undefined, + maxUses: inviteMaxUses, }); setInvites((prev) => [inv, ...prev]); setInviteEmail(''); setStatusMsg({ type: 'ok', - text: `Einladungscode ${inv.code} erfolgreich erstellt.`, + text: + inv.maxUses > 1 + ? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).` + : `Einladungscode ${inv.code} erfolgreich erstellt.`, }); } catch (err: any) { setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' }); @@ -221,12 +238,45 @@ export const AdminSettingsView: React.FC = ({ } }; - const handleCopyInviteCode = (inv: UserInvite) => { - navigator.clipboard.writeText(inv.code); + // Copies the code or the link that opens the sign-up form with the code filled in + const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`; + const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code'); + const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => { + navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code); + setCopiedWhat(what); setCopiedInviteId(inv.id); setTimeout(() => setCopiedInviteId(null), 2000); }; + const handleSendInvite = async (inv: UserInvite) => { + if (!mailInviteTo.trim()) return; + setMailInviteBusy(true); + setStatusMsg(null); + try { + const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim()); + setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i))); + setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` }); + setMailInviteId(null); + setMailInviteTo(''); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' }); + } finally { + setMailInviteBusy(false); + } + }; + + // Registrations waiting for approval (registration mode "Mit Freigabe") + const handleApproveUser = async (u: User) => { + setStatusMsg(null); + try { + const updated = await api.adminApproveUser(u.id); + setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item))); + setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` }); + } catch (err: any) { + setStatusMsg({ type: 'err', text: err.message }); + } + }; + const handleChangeUserRole = async (u: User, role: UserRole) => { setStatusMsg(null); try { @@ -273,7 +323,9 @@ export const AdminSettingsView: React.FC = ({ const handleDeleteUser = async (u: User) => { if ( !window.confirm( - `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?` + u.pendingApproval + ? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.` + : `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?` ) ) return; @@ -642,21 +694,35 @@ export const AdminSettingsView: React.FC = ({

- Einladungscode erzeugen (gültig für 7 Tage) + Einladung erzeugen (gültig für 7 Tage)

-
+
+
+ + setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))} + title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe" + className="w-full min-w-0 h-8 px-2.5 input-field" + /> +
+
1 ? '' : inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} - placeholder="Leer lassen für beliebige E-Mail" - className="w-full min-w-0 h-8 px-2.5 input-field" + disabled={inviteMaxUses > 1} + placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'} + className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60" />
@@ -697,7 +763,7 @@ export const AdminSettingsView: React.FC = ({ className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main" > - Einladungscode generieren + {inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : 'Einladungscode generieren'}
@@ -709,63 +775,165 @@ export const AdminSettingsView: React.FC = ({ Aktive & bisherige Einladungscodes:

- {invites.map((inv) => ( -
-
- - {inv.code} - - - Rolle: {inv.role} - {inv.email ? ` · Für: ${inv.email}` : ''} - {inv.organizationName ? ` · ${inv.organizationName}` : ''} - - {inv.usedBy ? ( - - Eingelöst - - ) : ( - - Offen - - )} -
+ {invites.map((inv) => { + const maxUses = inv.maxUses || 1; + const used = inv.useCount ?? (inv.usedBy ? 1 : 0); + const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now(); + const copied = copiedInviteId === inv.id; + return ( +
+
+
+ + {inv.code} + + + Rolle: {inv.role} + {inv.email ? ` · Für: ${inv.email}` : ''} + {inv.organizationName ? ` · ${inv.organizationName}` : ''} + {maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''} + + {open ? ( + Offen + ) : ( + {used >= maxUses ? 'Eingelöst' : 'Abgelaufen'} + )} +
-
- {!inv.usedBy && ( - + + + + )} + +
+
+ {mailInviteId === inv.id && ( +
{ + e.preventDefault(); + void handleSendInvite(inv); + }} + className="flex flex-wrap items-center gap-2" > - - {copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'} - + setMailInviteTo(e.target.value)} + readOnly={Boolean(inv.email)} + placeholder="name@beispiel.de" + required + autoFocus + className="flex-1 min-w-[12rem] h-8 px-2.5 input-field" + /> + + {maxUses === 1 && !inv.email && ( + Die Einladung gilt danach nur noch für diese Adresse. + )} + )} -
-
- ))} + ); + })}
)}
+ {/* Registrations waiting for approval */} + {users.some((u) => u.pendingApproval) && ( +
+

+ + Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length}) +

+
+ {users + .filter((u) => u.pendingApproval) + .map((u) => ( +
+
+ +
+

{u.displayName}

+

+ {u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')} +

+
+
+
+ + +
+
+ ))} +
+
+ )} + {/* Users List */}

- Registrierte Benutzer ({users.length}) + Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})

- {users.map((u) => ( + {users.filter((u) => !u.pendingApproval).map((u) => (
@@ -1136,27 +1304,55 @@ export const AdminSettingsView: React.FC = ({ />
- {/* Public Registration Toggle Switch */} -
-
-

Öffentliche Selbstregistrierung erlauben

-

- Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren. - Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden. -

-
- -
- - handleSaveSystemSettings({ allowRegistration })} - title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'} - /> -
-
+ {/* Who may register without an invite */} + {(() => { + const mode = settings.registrationMode || 'closed'; + const locked = Boolean(settings.registrationConfiguredViaEnv); + const needsDomains = mode === 'domain' || mode === 'domain+approval'; + return ( +
+
+

Registrierung ohne Einladung

+

+ Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren + immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei. +

+
+ + {needsDomains && ( + + )} + {locked && ( +

+ Über die Umgebung festgelegt (REGISTRATION_MODE /{' '} + REGISTRATION_DOMAINS) und hier nicht änderbar. +

+ )} +
+ ); + })()}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index aa079c8..c9c120e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -271,12 +271,13 @@ export const api = { password: string, displayName: string, inviteCode?: string - ): Promise<{ token: string; user: User }> { - const res = await request<{ token: string; user: User }>('/api/v1/auth/register', { + ): Promise<{ token?: string; user?: User; pending?: boolean; message?: string }> { + const res = await request<{ token?: string; user?: User; pending?: boolean; message?: string }>('/api/v1/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }), }); - setToken(res.token); + // Waiting for approval: no session yet + if (res.token) setToken(res.token); return res; }, @@ -1121,6 +1122,7 @@ export const api = { email?: string; role: string; organizationId?: string; + maxUses?: number; }): Promise { return request('/api/v1/admin/invites', { method: 'POST', @@ -1128,6 +1130,14 @@ export const api = { }); }, + async adminSendInvite(id: string, email: string): Promise { + return request(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) }); + }, + + async adminApproveUser(id: string): Promise { + return request(`/api/v1/admin/users/${id}/approve`, { method: 'POST' }); + }, + async adminDeleteInvite(id: string): Promise { await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' }); }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 727d792..bac9554 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,9 +2,14 @@ export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted'; export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent'; export type UserRole = 'admin' | 'member'; +export type RegistrationMode = 'closed' | 'approval' | 'domain' | 'domain+approval' | 'open'; + export interface AuthStatus { needsSetup: boolean; allowRegistration: boolean; + // Registering without an invite and the e-mail domains of the domain modes (hint on the form) + registrationMode?: RegistrationMode; + registrationDomains?: string[]; instanceName: string; demoMode?: boolean; sharingMode?: SharingMode; @@ -94,6 +99,8 @@ export interface User { avatarUrl?: string; role: UserRole; isActive: boolean; + // Registered and waiting for an admin's approval (admin user list) + pendingApproval?: boolean; alarmChannels?: string[]; alarmEmail?: string; discordWebhookUrl?: string; @@ -115,6 +122,9 @@ export interface UserInvite { usedBy?: string; createdAt: string; expiresAt: string; + // A link for several people: allowed and used redemptions + maxUses: number; + useCount: number; } export interface SubTask { @@ -250,6 +260,10 @@ export interface SystemSettings { smtpSkipVerify?: boolean; allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http) smtpConfiguredViaEnv?: boolean; + registrationMode?: RegistrationMode; + registrationDomains?: string; + // REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true) fix it: shown, not editable + registrationConfiguredViaEnv?: boolean; // Automatic backups: daily time (HH:MM, server time zone) and days to keep them backupEnabled?: boolean; backupTime?: string; diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index d6ef733..8cccf5e 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" ) // --- ORGANIZATIONS & TEAMS --- @@ -93,6 +94,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + pending := s.store.PendingRegistrationIDs() + for i := range users { + users[i].PendingApproval = pending[users[i].ID] + } writeJSON(w, http.StatusOK, users) } @@ -191,6 +196,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { if req.IsActive != nil && !*req.IsActive { s.wsHub.DisconnectUser(targetUserID) } + if req.IsActive != nil && *req.IsActive { + s.store.ClearPendingRegistration(targetUserID) // activating by hand counts as approval + } writeJSON(w, http.StatusOK, u) } @@ -247,6 +255,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { return } s.removeUserBackups(targetUserID) + s.store.ClearPendingRegistration(targetUserID) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } @@ -270,6 +279,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) Role string `json:"role"` OrganizationID string `json:"organizationId"` ValidDays int `json:"validDays"` + MaxUses int `json:"maxUses"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -278,7 +288,7 @@ func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) if req.Role == "admin" && (caller == nil || caller.Role != "admin") { req.Role = "member" } - inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays) + inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays, req.MaxUses) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return @@ -312,6 +322,16 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys out.SMTPPassSet = out.SMTPPass != "" out.SMTPPass = "" out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != "" + // What the environment fixes is shown as in effect and locked in the form + if s.cfg.RegistrationMode != "" { + out.RegistrationMode = s.cfg.RegistrationMode + out.AllowRegistration = out.RegistrationMode == storage.RegistrationOpen + out.RegistrationConfiguredViaEnv = true + } + if s.cfg.RegistrationDomains != "" { + out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ") + out.RegistrationConfiguredViaEnv = true + } return &out } @@ -327,6 +347,17 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques settings.SMTPPass = cur.SMTPPass } } + // Registration fixed by the environment cannot be changed here (the stored value stays as it was) + if s.cfg.RegistrationMode != "" || s.cfg.RegistrationDomains != "" { + if cur, err := s.store.GetSystemSettings(); err == nil { + if s.cfg.RegistrationMode != "" { + settings.RegistrationMode, settings.AllowRegistration = cur.RegistrationMode, cur.AllowRegistration + } + if s.cfg.RegistrationDomains != "" { + settings.RegistrationDomains = cur.RegistrationDomains + } + } + } previousMode := s.store.SharingMode() if err := s.store.UpdateSystemSettings(&settings); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 3f001b7..2e48dff 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -29,21 +29,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { count, _ := s.store.CountUsers() sys, _ := s.store.GetSystemSettings() - allowReg := false + policy := s.registrationPolicy() instanceName := "MindSift" if sys != nil { - allowReg = sys.AllowRegistration if sys.InstanceName != "" { instanceName = sys.InstanceName } } status := map[string]interface{}{ - "needsSetup": count == 0, - "allowRegistration": allowReg, - "instanceName": instanceName, - "demoMode": s.cfg.DemoMode, - "sharingMode": s.store.SharingMode(), - "passwordReset": s.passwordResetAvailable(), + "needsSetup": count == 0, + // Registering without an invite: allowed at all, how (closed | approval | domain | domain+approval | + // open) and for which e-mail domains (shown as a hint on the sign-up form) + "allowRegistration": policy.Mode != storage.RegistrationClosed, + "registrationMode": policy.Mode, + "registrationDomains": policy.Domains, + "instanceName": instanceName, + "demoMode": s.cfg.DemoMode, + "sharingMode": s.store.SharingMode(), + "passwordReset": s.passwordResetAvailable(), // How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only "voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds}, // Public so the login screen can link Impressum and Datenschutz @@ -131,6 +134,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { sys.InstanceName = strings.TrimSpace(req.InstanceName) } sys.AllowRegistration = req.AllowRegistration + sys.RegistrationMode = storage.RegistrationClosed + if req.AllowRegistration { + sys.RegistrationMode = storage.RegistrationOpen + } sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode) if err := s.store.UpdateSystemSettings(sys); err != nil { writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error()) @@ -191,6 +198,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { count, _ := s.store.CountUsers() role := "member" var validInvite *domain.UserInvite + pending := false // registered without invite while approval is needed if strings.TrimSpace(req.InviteCode) != "" { inv, err := s.store.GetUserInviteByCode(req.InviteCode) @@ -207,9 +215,14 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } else if count == 0 { role = "admin" } else { - sysSettings, _ := s.store.GetSystemSettings() - if sysSettings == nil || !sysSettings.AllowRegistration { - writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator") + var err error + pending, err = s.registrationPolicy().registrationOutcome(req.Email) + if err != nil { + writeError(w, http.StatusForbidden, err.Error()) + return + } + if pending && len(s.store.PendingRegistrationIDs()) >= storage.MaxPendingRegistrations { + writeError(w, http.StatusTooManyRequests, "Gerade warten zu viele Registrierungen auf Freigabe – bitte später erneut versuchen") return } } @@ -248,6 +261,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } } + // Waiting for approval: the account exists but stays locked, no session yet + if pending { + inactive := false + if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil { + writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden") + return + } + if err := s.store.AddPendingRegistration(user.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden") + return + } + slog.Info("Registrierung wartet auf Freigabe", "user_id", user.ID, "email", user.Email) + go s.notifyAdminsOfRegistration(user) + writeJSON(w, http.StatusAccepted, map[string]interface{}{ + "pending": true, + "message": "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst.", + }) + return + } + token, err := s.startSession(r, user) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") @@ -310,6 +343,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } s.loginGuard.recordSuccess(account, ip) + if !user.IsActive && s.store.IsPendingRegistration(user.ID) { + writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator") + return + } if !user.IsActive { slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr) writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert") diff --git a/internal/api/register_limits_test.go b/internal/api/register_limits_test.go index 223b08c..19435d8 100644 --- a/internal/api/register_limits_test.go +++ b/internal/api/register_limits_test.go @@ -12,7 +12,7 @@ import ( func TestInviteCodeRedeemedOnlyOnce(t *testing.T) { srv, store, _ := setupTestServer(t) router := srv.Router() - inv, err := store.CreateUserInvite("", "", "member", "", 7) + inv, err := store.CreateUserInvite("", "", "member", "", 7, 1) if err != nil { t.Fatal(err) } @@ -46,7 +46,7 @@ func TestInviteCodeRedeemedOnlyOnce(t *testing.T) { func TestInviteReleasedWhenAccountFails(t *testing.T) { srv, store, _ := setupTestServer(t) router := srv.Router() - inv, _ := store.CreateUserInvite("", "", "member", "", 7) + inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1) _, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member") body := func(email string) string { return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}` diff --git a/internal/api/registration.go b/internal/api/registration.go new file mode 100644 index 0000000..97d5474 --- /dev/null +++ b/internal/api/registration.go @@ -0,0 +1,209 @@ +package api + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" +) + +// Registration without an invite (mode and e-mail domains), approval of waiting accounts, and sending +// invite links by mail. See Doku/plans/registrierung-und-login.md. + +type registrationPolicy struct { + Mode string + Domains []string + ViaEnv bool +} + +// registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin +// settings, so an operator can lock it down for good. +func (s *Server) registrationPolicy() registrationPolicy { + p := registrationPolicy{Mode: storage.RegistrationClosed} + if sys, _ := s.store.GetSystemSettings(); sys != nil { + p.Mode = sys.RegistrationMode + p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains) + } + if s.cfg.RegistrationMode != "" { + p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true + } + if s.cfg.RegistrationDomains != "" { + p.Domains, p.ViaEnv = storage.ParseEmailDomains(s.cfg.RegistrationDomains), true + } + return p +} + +// registrationOutcome decides for a sign-up without invite: allowed at all, and active at once or +// waiting for approval. The error is shown to the person. +func (p registrationPolicy) registrationOutcome(email string) (pending bool, err error) { + inDomains := storage.EmailInDomains(email, p.Domains) + switch p.Mode { + case storage.RegistrationOpen: + return false, nil + case storage.RegistrationApproval: + return true, nil + case storage.RegistrationDomain: + if !inDomains { + return false, errors.New("Registrierung nur mit E-Mail-Adressen von " + domainList(p.Domains)) + } + return false, nil + case storage.RegistrationDomainApproval: + return !inDomains, nil + } + return false, errors.New("Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator") +} + +func domainList(domains []string) string { + if len(domains) == 0 { + return "freigegebenen Domains" + } + out := make([]string, len(domains)) + for i, d := range domains { + out[i] = "@" + strings.TrimPrefix(d, "*.") + } + return strings.Join(out, ", ") +} + +// appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the +// admin is using right now (only for mails an admin triggers, never for anonymous requests). +func (s *Server) appLink(r *http.Request) string { + if s.cfg.BaseURLSet { + return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Header.Get("X-Forwarded-Host") + if host == "" { + host = r.Host + } + return scheme + "://" + host + "/" +} + +// notifyAdminsOfRegistration mails every active admin that someone waits (only with SMTP). +func (s *Server) notifyAdminsOfRegistration(user *domain.User) { + if _, err := s.smtpSettings(); err != nil { + return + } + users, err := s.store.GetAllUsers() + if err != nil { + return + } + for _, a := range users { + if a.Role != "admin" || !a.IsActive || a.Email == "" { + continue + } + c := mailContent{ + Preheader: user.DisplayName + " wartet auf Freigabe", + Greeting: mailGreeting(time.Now(), a.DisplayName), + Intro: "Jemand hat sich bei MindSift registriert und wartet auf deine Freigabe:", + Sections: []mailSection{{Items: []mailItem{{Title: user.DisplayName, Meta: user.Email}}}}, + Outro: "Freigeben oder ablehnen kannst du in den Administrator-Einstellungen unter „Benutzer & Einladungen“.", + Footer: "Du bekommst diese E-Mail als Administrator dieser MindSift-Instanz.", + } + if link := s.notifyLink(); link != "" { + c.Button = &mailButton{Label: "Benutzerverwaltung öffnen", URL: link} + } + if err := s.sendMailContent(a.Email, "MindSift: Neue Registrierung wartet auf Freigabe", c); err != nil { + slog.Warn("Hinweis auf neue Registrierung nicht verschickt", "admin", a.ID, "error", err) + } + } +} + +// handleAdminApproveUser: POST /admin/users/{id}/approve – a waiting account becomes active. +func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + user, err := s.store.GetUserByID(userID) + if err != nil || user == nil { + writeError(w, http.StatusNotFound, "Benutzer nicht gefunden") + return + } + ok, err := s.store.ApprovePendingRegistration(userID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !ok { + writeError(w, http.StatusConflict, "Dieses Konto wartet nicht auf Freigabe") + return + } + user.IsActive = true + link := s.appLink(r) + go func() { + _ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{ + Preheader: "Du kannst dich jetzt anmelden", + Greeting: mailGreeting(time.Now(), user.DisplayName), + Intro: "Dein MindSift-Konto ist freigeschaltet. Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden.", + Button: &mailButton{Label: "Zu MindSift", URL: link}, + Footer: mailFooterAccount, + }) + }() + writeJSON(w, http.StatusOK, user) +} + +// handleAdminSendInvite: POST /admin/invites/{id}/send {"email"} – mails the invite link. A single-use +// invite is bound to that address on the way. +func (s *Server) handleAdminSendInvite(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !strings.Contains(req.Email, "@") { + writeError(w, http.StatusBadRequest, "Bitte eine gültige E-Mail-Adresse angeben") + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := s.smtpSettings(); err != nil { + writeError(w, http.StatusBadRequest, "Für Einladungen per E-Mail muss ein Mail-Server eingerichtet sein") + return + } + inv, err := s.store.GetUserInviteByID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if inv.MaxUses == 1 { + if inv.Email != "" && !strings.EqualFold(inv.Email, email) { + writeError(w, http.StatusConflict, "Diese Einladung ist an "+inv.Email+" gebunden") + return + } + if inv.Email == "" { + if err := s.store.BindUserInviteEmail(inv.ID, email); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + inv.Email = email + } + } + + inviter := "Jemand" + if me, _ := s.store.GetUserByID(auth.GetUserID(r.Context())); me != nil && me.DisplayName != "" { + inviter = me.DisplayName + } + link := s.appLink(r) + "?invite=" + url.QueryEscape(inv.Code) + until := inv.ExpiresAt + if t, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil { + until = t.Local().Format("02.01.2006") + } + err = s.sendMailContent(email, inviter+" lädt dich zu MindSift ein", mailContent{ + Preheader: inviter + " lädt dich zu MindSift ein", + Greeting: "Hallo", + Intro: inviter + " lädt dich zu MindSift ein – Gedanken abladen, Aufgaben, Notizen und gemeinsame Listen an einem Ort.", + Button: &mailButton{Label: "Konto anlegen", URL: link}, + Note: "Die Einladung gilt bis " + until + ". Falls der Knopf nicht geht: " + link + "\nEinladungscode: " + inv.Code, + Footer: "Du bekommst diese E-Mail, weil dich jemand zu MindSift eingeladen hat.", + }) + if err != nil { + writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, inv) +} diff --git a/internal/api/registration_test.go b/internal/api/registration_test.go new file mode 100644 index 0000000..e795cda --- /dev/null +++ b/internal/api/registration_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" +) + +func setRegistration(t *testing.T, store *storage.Store, mode, domains string) { + t.Helper() + sys, _ := store.GetSystemSettings() + sys.RegistrationMode, sys.RegistrationDomains = mode, domains + if err := store.UpdateSystemSettings(sys); err != nil { + t.Fatal(err) + } +} + +func register(srv *Server, email, invite string) (int, map[string]interface{}) { + body := `{"email":"` + email + `","password":"password123","displayName":"` + strings.Split(email, "@")[0] + `","inviteCode":"` + invite + `"}` + rec := sessionCall(srv.Router(), "POST", "/api/v1/auth/register", "", body, "") + var out map[string]interface{} + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +func TestRegistrationModes(t *testing.T) { + srv, store, _ := setupTestServer(t) + + setRegistration(t, store, storage.RegistrationClosed, "") + if code, _ := register(srv, "a@example.com", ""); code != http.StatusForbidden { + t.Fatalf("closed: want 403, got %d", code) + } + + setRegistration(t, store, storage.RegistrationOpen, "") + if code, out := register(srv, "b@example.com", ""); code != http.StatusCreated || out["token"] == nil { + t.Fatalf("open: want 201 with token, got %d %v", code, out) + } + + setRegistration(t, store, storage.RegistrationDomain, "firma.de, @familie.de") + if code, _ := register(srv, "c@fremd.de", ""); code != http.StatusForbidden { + t.Fatalf("domain: foreign address must be refused, got %d", code) + } + if code, _ := register(srv, "c@Familie.DE", ""); code != http.StatusCreated { + t.Fatalf("domain: listed domain (any case) must be active at once, got %d", code) + } + + setRegistration(t, store, storage.RegistrationDomainApproval, "firma.de") + if code, _ := register(srv, "d@firma.de", ""); code != http.StatusCreated { + t.Fatalf("domain+approval: listed domain active at once, got %d", code) + } + if code, out := register(srv, "e@fremd.de", ""); code != http.StatusAccepted || out["pending"] != true || out["token"] != nil { + t.Fatalf("domain+approval: others wait without session, got %d %v", code, out) + } + + // An invite skips mode and domain check + setRegistration(t, store, storage.RegistrationClosed, "") + inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1) + if code, _ := register(srv, "f@fremd.de", inv.Code); code != http.StatusCreated { + t.Fatalf("invite must work in closed mode, got %d", code) + } +} + +func TestApprovalFlow(t *testing.T) { + srv, store, adminToken := setupTestServer(t) + router := srv.Router() + setRegistration(t, store, storage.RegistrationApproval, "") + + if code, _ := register(srv, "neu@example.com", ""); code != http.StatusAccepted { + t.Fatalf("approval: want 202, got %d", code) + } + login := func() *http.Response { + rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "") + return rec.Result() + } + res := login() + var errBody map[string]string + _ = json.NewDecoder(res.Body).Decode(&errBody) + if res.StatusCode != http.StatusForbidden || !strings.Contains(errBody["error"], "Freigabe") { + t.Fatalf("login before approval: want 403 mentioning Freigabe, got %d %v", res.StatusCode, errBody) + } + + // The admin sees the account as waiting and approves it + var users []domain.User + _ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/users", adminToken, "", "").Body.Bytes(), &users) + var waiting *domain.User + for i := range users { + if users[i].Email == "neu@example.com" { + waiting = &users[i] + } + } + if waiting == nil || !waiting.PendingApproval || waiting.IsActive { + t.Fatalf("admin list must show the waiting account, got %+v", waiting) + } + _, memberToken := newTestUser(t, store, "member@example.com") + if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", memberToken, "", ""); rec.Code != http.StatusForbidden { + t.Fatalf("members must not approve, got %d", rec.Code) + } + if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusOK { + t.Fatalf("approve: %d %s", rec.Code, rec.Body.String()) + } + if res := login(); res.StatusCode != http.StatusOK { + t.Fatalf("login after approval: want 200, got %d", res.StatusCode) + } + if rec := sessionCall(router, "POST", "/api/v1/admin/users/"+waiting.ID+"/approve", adminToken, "", ""); rec.Code != http.StatusConflict { + t.Fatalf("approving twice: want 409, got %d", rec.Code) + } +} + +func TestInviteLinkForSeveralPeople(t *testing.T) { + srv, store, _ := setupTestServer(t) + if _, err := store.CreateUserInvite("", "x@example.com", "member", "", 7, 3); err == nil { + t.Fatal("a link for several people must not be bound to one address") + } + inv, err := store.CreateUserInvite("", "", "member", "", 7, 2) + if err != nil { + t.Fatal(err) + } + for _, email := range []string{"eins@example.com", "zwei@example.com"} { + if code, out := register(srv, email, inv.Code); code != http.StatusCreated { + t.Fatalf("%s: want 201, got %d %v", email, code, out) + } + } + if code, out := register(srv, "drei@example.com", inv.Code); code != http.StatusForbidden || !strings.Contains(out["error"].(string), "ausgeschöpft") { + t.Fatalf("third use: want 403 'ausgeschöpft', got %d %v", code, out) + } + invites, _ := store.GetUserInvites() + if len(invites) != 1 || invites[0].UseCount != 2 || invites[0].MaxUses != 2 { + t.Fatalf("use count wrong: %+v", invites) + } +} + +func TestRegistrationFixedByEnvironment(t *testing.T) { + srv, store, adminToken := setupTestServer(t) + router := srv.Router() + srv.cfg.RegistrationMode = storage.RegistrationClosed + + // The admin form shows the fixed value as locked and cannot change it + rec := sessionCall(router, "PUT", "/api/v1/admin/settings", adminToken, `{"registrationMode":"open","allowRegistration":true,"instanceName":"MindSift"}`, "") + if rec.Code != http.StatusOK { + t.Fatalf("save settings: %d %s", rec.Code, rec.Body.String()) + } + var sys domain.SystemSettings + _ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/admin/settings", adminToken, "", "").Body.Bytes(), &sys) + if sys.RegistrationMode != storage.RegistrationClosed || !sys.RegistrationConfiguredViaEnv { + t.Fatalf("environment must win and be marked, got %+v", sys) + } + if code, _ := register(srv, "x@example.com", ""); code != http.StatusForbidden { + t.Fatalf("registration must stay closed, got %d", code) + } + if stored, _ := store.GetSystemSettings(); stored.RegistrationMode == storage.RegistrationOpen { + t.Fatalf("the locked value must not be overwritten in the database") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0db7a35..a6e0750 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -290,10 +290,12 @@ func (s *Server) Router() http.Handler { r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser)) r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword)) r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser)) + r.Post("/api/v1/admin/users/{id}/approve", s.notInDemo(s.handleAdminApproveUser)) r.Get("/api/v1/admin/invites", s.handleAdminGetInvites) r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite) r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite) + r.Post("/api/v1/admin/invites/{id}/send", s.notInDemo(s.handleAdminSendInvite)) }) // Server-Admin Strictly Protected Routes diff --git a/internal/config/config.go b/internal/config/config.go index c21b414..63ce408 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,15 @@ type Config struct { // from it, never from the request's Host header. BaseURLSet bool AllowRegistration bool - LogLevel string - Debug bool - DemoMode bool + // RegistrationMode / RegistrationDomains: REGISTRATION_MODE (closed | approval | domain | + // domain+approval | open) and REGISTRATION_DOMAINS. When set they win over the admin settings, + // which then cannot change them. ALLOW_REGISTRATION=true counts as REGISTRATION_MODE=open; + // ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings. + RegistrationMode string + RegistrationDomains string + LogLevel string + Debug bool + DemoMode bool // DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never) DemoResetMinutes int // TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs) @@ -234,6 +240,8 @@ func Load() *Config { BaseURL: baseURL, BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", AllowRegistration: allowReg, + RegistrationMode: registrationMode(allowReg), + RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")), LogLevel: logLevel, Debug: debug, DemoMode: demoMode, @@ -326,3 +334,18 @@ func demoResetMinutes() int { } return n } + +// registrationMode reads REGISTRATION_MODE; unknown values are ignored ("" = admin settings decide). +func registrationMode(allowReg bool) string { + switch m := strings.ToLower(strings.TrimSpace(os.Getenv("REGISTRATION_MODE"))); m { + case "closed", "approval", "domain", "domain+approval", "open": + return m + case "": + default: + slog.Warn("REGISTRATION_MODE unbekannt, wird ignoriert", "value", m) + } + if allowReg { + return "open" + } + return "" +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 5bc2a95..88fc5a7 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -135,6 +135,8 @@ type User struct { NotifyBeforeMinutes int `json:"notifyBeforeMinutes"` OrganizationID string `json:"organizationId,omitempty"` CreatedAt string `json:"createdAt"` + // Registered while registration needs approval and not approved yet (admin user list only) + PendingApproval bool `json:"pendingApproval,omitempty"` // Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings) NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"` } @@ -216,7 +218,10 @@ type UserInvite struct { CreatedBy string `json:"createdBy"` ExpiresAt string `json:"expiresAt"` UsedBy string `json:"usedBy,omitempty"` - CreatedAt string `json:"createdAt"` + // A link for several people: how often it may be used and how often it was + MaxUses int `json:"maxUses"` + UseCount int `json:"useCount"` + CreatedAt string `json:"createdAt"` } type ThoughtStatus string @@ -350,12 +355,19 @@ type TeamMember struct { } type SystemSettings struct { - SharingMode string `json:"sharingMode"` // private | organizations | open - AllowRegistration bool `json:"allowRegistration"` - InstanceName string `json:"instanceName"` - SMTPHost string `json:"smtpHost"` - SMTPPort string `json:"smtpPort"` - SMTPUser string `json:"smtpUser"` + SharingMode string `json:"sharingMode"` // private | organizations | open + // AllowRegistration mirrors RegistrationMode == "open" (older clients and the setup wizard) + AllowRegistration bool `json:"allowRegistration"` + // Who may register without an invite: closed | approval | domain | domain+approval | open + RegistrationMode string `json:"registrationMode"` + // E-mail domains for the domain modes, comma separated ("firma.de, familie.de") + RegistrationDomains string `json:"registrationDomains"` + // Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here + RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"` + InstanceName string `json:"instanceName"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUser string `json:"smtpUser"` // SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet; // writing it empty keeps the stored password SMTPPass string `json:"smtpPass"` diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 1cff2ac..26c0b70 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -30,6 +30,7 @@ var migrations = []migration{ {7, "Benachrichtigungen", (*Store).migrateNotifications}, {8, "Journal", (*Store).migrateJournal}, {9, "Web-Push", (*Store).migratePush}, + {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, } func (s *Store) migrate() error { diff --git a/internal/storage/registration.go b/internal/storage/registration.go new file mode 100644 index 0000000..1874f66 --- /dev/null +++ b/internal/storage/registration.go @@ -0,0 +1,127 @@ +package storage + +import ( + "strings" + "time" +) + +// Registration without an invite: who may register (mode), from which e-mail domains, and accounts +// that wait for an admin's approval (they exist with is_active = 0 until approved). + +const ( + RegistrationClosed = "closed" // only with an invite + RegistrationApproval = "approval" // anyone, active after approval + RegistrationDomain = "domain" // only listed e-mail domains, active at once + RegistrationDomainApproval = "domain+approval" // listed domains at once, others after approval + RegistrationOpen = "open" // anyone, active at once + + // MaxPendingRegistrations stops a flood of sign-ups while approval is needed + MaxPendingRegistrations = 50 +) + +// NormalizeRegistrationMode returns a known mode, "" for anything else. +func NormalizeRegistrationMode(mode string) string { + switch m := strings.ToLower(strings.TrimSpace(mode)); m { + case RegistrationClosed, RegistrationApproval, RegistrationDomain, RegistrationDomainApproval, RegistrationOpen: + return m + } + return "" +} + +// ParseEmailDomains turns "firma.de, @Familie.de" into ["firma.de", "familie.de"]. +func ParseEmailDomains(raw string) []string { + seen := map[string]bool{} + out := []string{} + for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) { + d := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(part), "@")) + if d == "" || !strings.Contains(d, ".") || seen[d] { + continue + } + seen[d] = true + out = append(out, d) + } + return out +} + +// EmailInDomains: the part after "@" equals one of the domains; "*.firma.de" also allows subdomains. +func EmailInDomains(email string, domains []string) bool { + at := strings.LastIndex(email, "@") + if at < 0 { + return false + } + host := strings.ToLower(strings.TrimSpace(email[at+1:])) + for _, d := range domains { + if strings.HasPrefix(d, "*.") { + if base := d[2:]; host == base || strings.HasSuffix(host, "."+base) { + return true + } + } else if host == d { + return true + } + } + return false +} + +func (s *Store) migrateRegistration() error { + for _, q := range []string{ + `ALTER TABLE user_invites ADD COLUMN max_uses INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE user_invites ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0`, + `UPDATE user_invites SET use_count = 1 WHERE used_by != '' AND use_count = 0`, + `CREATE TABLE IF NOT EXISTS pending_registrations ( + user_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL + )`, + } { + if err := s.execSchemaStatement(q); err != nil { + return err + } + } + return nil +} + +func (s *Store) AddPendingRegistration(userID string) error { + _, err := s.db.Exec(`INSERT OR IGNORE INTO pending_registrations (user_id, created_at) VALUES (?, ?)`, + userID, time.Now().UTC().Format(time.RFC3339)) + return err +} + +func (s *Store) ClearPendingRegistration(userID string) { + _, _ = s.db.Exec(`DELETE FROM pending_registrations WHERE user_id = ?`, userID) +} + +func (s *Store) IsPendingRegistration(userID string) bool { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pending_registrations WHERE user_id = ?`, userID).Scan(&n) + return n > 0 +} + +// PendingRegistrationIDs: accounts that still wait for approval (only existing users). +func (s *Store) PendingRegistrationIDs() map[string]bool { + out := map[string]bool{} + rows, err := s.db.Query(`SELECT p.user_id FROM pending_registrations p JOIN users u ON u.id = p.user_id`) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + out[id] = true + } + } + return out +} + +// ApprovePendingRegistration activates a waiting account; false when it was not waiting. +func (s *Store) ApprovePendingRegistration(userID string) (bool, error) { + if !s.IsPendingRegistration(userID) { + return false, nil + } + if err := s.execInTx( + stmt(`UPDATE users SET is_active = 1 WHERE id = ?`, userID), + stmt(`DELETE FROM pending_registrations WHERE user_id = ?`, userID), + ); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/storage/system.go b/internal/storage/system.go index f1f5898..8399667 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -138,6 +138,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings.SharingMode = v case "allow_registration": settings.AllowRegistration = (v == "true" || v == "1") + case "registration_mode": + settings.RegistrationMode = NormalizeRegistrationMode(v) + case "registration_domains": + settings.RegistrationDomains = v case "instance_name": settings.InstanceName = v case "smtp_host": @@ -173,10 +177,28 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { } } } + // Older installations only knew the open/closed switch + if settings.RegistrationMode == "" { + settings.RegistrationMode = RegistrationClosed + if settings.AllowRegistration { + settings.RegistrationMode = RegistrationOpen + } + } + settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen return settings, nil } func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { + // Clients that only send the old switch keep working + settings.RegistrationMode = NormalizeRegistrationMode(settings.RegistrationMode) + if settings.RegistrationMode == "" { + settings.RegistrationMode = RegistrationClosed + if settings.AllowRegistration { + settings.RegistrationMode = RegistrationOpen + } + } + settings.AllowRegistration = settings.RegistrationMode == RegistrationOpen + settings.RegistrationDomains = strings.Join(ParseEmailDomains(settings.RegistrationDomains), ", ") regStr := "false" if settings.AllowRegistration { regStr = "true" @@ -221,7 +243,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { ('allow_private_webhooks', ?), ('backup_enabled', ?), ('backup_time', ?), - ('backup_retention_days', ?) + ('backup_retention_days', ?), + ('registration_mode', ?), + ('registration_domains', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settings.SharingMode, regStr, @@ -238,6 +262,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { strconv.FormatBool(settings.BackupEnabled), settings.BackupTime, strconv.Itoa(settings.BackupRetentionDays), + settings.RegistrationMode, + settings.RegistrationDomains, ) return err } diff --git a/internal/storage/users.go b/internal/storage/users.go index b342eb6..1237136 100644 --- a/internal/storage/users.go +++ b/internal/storage/users.go @@ -327,13 +327,25 @@ func (s *Store) RevokeAllUserTokens(userID string) error { // --- USER INVITES --- -func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays int) (*domain.UserInvite, error) { +// MaxInviteUses caps how many accounts one invite link can create. +const MaxInviteUses = 1000 + +func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays, maxUses int) (*domain.UserInvite, error) { if role != "admin" && role != "member" { role = "member" } if validDays <= 0 || validDays > 365 { validDays = 7 } + if maxUses < 1 { + maxUses = 1 + } + if maxUses > MaxInviteUses { + maxUses = MaxInviteUses + } + if maxUses > 1 && strings.TrimSpace(email) != "" { + return nil, errors.New("Ein Einladungslink für mehrere Personen kann nicht an eine E-Mail-Adresse gebunden werden") + } code := newInviteCode() now := time.Now().UTC() inv := &domain.UserInvite{ @@ -345,11 +357,12 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays CreatedBy: createdBy, ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339), CreatedAt: now.Format(time.RFC3339), + MaxUses: maxUses, } _, err := s.db.Exec( - `INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`, - inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, + `INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count) + VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, 0)`, + inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, inv.MaxUses, ) if err != nil { return nil, err @@ -359,7 +372,8 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays func (s *Store) GetUserInvites() ([]domain.UserInvite, error) { rows, err := s.db.Query( - `SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at + `SELECT i.id, i.code, i.email, i.role, i.organization_id, COALESCE(o.name, ''), i.created_by, i.expires_at, i.used_by, i.created_at, + i.max_uses, i.use_count FROM user_invites i LEFT JOIN organizations o ON o.id = i.organization_id ORDER BY i.created_at DESC`, @@ -372,7 +386,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) { invites := make([]domain.UserInvite, 0) for rows.Next() { var inv domain.UserInvite - if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt); err == nil { + if err := rows.Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.OrganizationName, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount); err == nil { invites = append(invites, inv) } } @@ -389,17 +403,28 @@ func newInviteCode() string { } func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) { - code = strings.ToUpper(strings.TrimSpace(code)) + return s.getUserInvite(`UPPER(code) = ?`, strings.ToUpper(strings.TrimSpace(code))) +} + +// GetUserInviteByID: a still usable invite by its id (e.g. to send it by mail). +func (s *Store) GetUserInviteByID(id string) (*domain.UserInvite, error) { + return s.getUserInvite(`id = ?`, id) +} + +func (s *Store) getUserInvite(where string, arg string) (*domain.UserInvite, error) { var inv domain.UserInvite err := s.db.QueryRow( - `SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at - FROM user_invites WHERE UPPER(code) = ?`, - code, - ).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt) + `SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count + FROM user_invites WHERE `+where, + arg, + ).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt, &inv.MaxUses, &inv.UseCount) if err != nil { return nil, errors.New("Ungültiger Einladungscode") } - if inv.UsedBy != "" { + if inv.UseCount >= inv.MaxUses { + if inv.MaxUses > 1 { + return nil, errors.New("Dieser Einladungslink ist bereits voll ausgeschöpft") + } return nil, errors.New("Dieser Einladungscode wurde bereits eingelöst") } if exp, err := time.Parse(time.RFC3339, inv.ExpiresAt); err == nil && time.Now().UTC().After(exp) { @@ -408,30 +433,36 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) { return &inv, nil } -// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with -// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or -// ReleaseUserInvite. +// ClaimUserInvite reserves one use of an invite before the account is created, so registrations with +// the same code can never use it more often than allowed, even at the same moment (FLAW-40). The +// returned marker goes to FinishUserInvite or ReleaseUserInvite. func (s *Store) ClaimUserInvite(inviteID string) (string, error) { - claim := domain.NewID("claim") - res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID) + res, err := s.db.Exec(`UPDATE user_invites SET use_count = use_count + 1 WHERE id = ? AND use_count < max_uses`, inviteID) if err != nil { return "", err } if n, _ := res.RowsAffected(); n != 1 { return "", errors.New("Dieser Einladungscode wurde bereits eingelöst") } - return claim, nil + return domain.NewID("claim"), nil } -// FinishUserInvite records the new account on a claimed invite. +// FinishUserInvite records the new account on a single-use invite (links for several people only count). func (s *Store) FinishUserInvite(inviteID, claim, userID string) error { - _, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim) + _, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND max_uses = 1`, userID, inviteID) return err } -// ReleaseUserInvite frees a claimed invite again when the account could not be created. +// ReleaseUserInvite gives the reserved use back when the account could not be created. func (s *Store) ReleaseUserInvite(inviteID, claim string) { - _, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim) + _, _ = s.db.Exec(`UPDATE user_invites SET use_count = use_count - 1 WHERE id = ? AND use_count > 0`, inviteID) +} + +// BindUserInviteEmail ties a single-use invite to the address it is sent to. +func (s *Store) BindUserInviteEmail(inviteID, email string) error { + _, err := s.db.Exec(`UPDATE user_invites SET email = ? WHERE id = ? AND max_uses = 1 AND email = ''`, + strings.ToLower(strings.TrimSpace(email)), inviteID) + return err } func (s *Store) DeleteUserInvite(inviteID string) error {