feat(auth): invite links for several people, invite by email, registration with approval or email domains
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
db5c5cde9b
commit
af77b9e809
24 files changed
+1034
-99
No files matched your search
+10
-2
@@ -18,8 +18,16 @@ BASE_URL=http://localhost:8080
|
|||||||
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
|
# Registrierung ohne Einladung. Ohne Angabe entscheidet Administration → Server & Isolation.
|
||||||
# Lässt sich später in Administration → Server & Isolation ändern.
|
# 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
|
ALLOW_REGISTRATION=false
|
||||||
|
|
||||||
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+13
-1
@@ -6,6 +6,17 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.22.2] - 2026-10-31
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
- 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.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.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
|
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
|
||||||
|
|||||||
+5
-3
@@ -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 |
|
| 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 | `/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/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/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/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 |
|
| 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 |
|
| 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`) |
|
| GET/POST | `/admin/organizations` | Organisationen mit `memberCount`; anlegen (`name`) |
|
||||||
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
| 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 |
|
| 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=<CODE>` ö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 |
|
| 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/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`) |
|
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||||
|
|||||||
@@ -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 |
|
| `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:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
|
| `BASE_URL` | `http://localhost:<PORT>` | 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`) |
|
| `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` |
|
| `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`) |
|
| `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`. |
|
| `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`. |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Plan: Registrierung und Anmeldung (Entwurf)
|
# 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
|
## Heute
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](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.
|
**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
|
### 🏢 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.
|
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
|
* 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)) |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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_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 |
|
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.22.2",
|
"version": "0.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.22.2",
|
"version": "0.23.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.22.2",
|
"version": "0.23.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -43,13 +43,37 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const [createSampleData, setCreateSampleData] = useState(false);
|
const [createSampleData, setCreateSampleData] = useState(false);
|
||||||
|
|
||||||
// --- Login / Invite / Register State ---
|
// --- 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'>(
|
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||||
resetToken ? 'reset' : 'login'
|
resetToken ? 'reset' : linkInviteCode ? 'invite' : 'login'
|
||||||
);
|
);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [displayName, setDisplayName] = 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 [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -167,6 +191,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
try {
|
try {
|
||||||
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
||||||
const res = await api.register(email.trim(), password, displayName.trim(), code);
|
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);
|
onAuthenticated(res.user);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Registrierung fehlgeschlagen');
|
setError(err.message || 'Registrierung fehlgeschlagen');
|
||||||
@@ -687,6 +718,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
{(authTab === 'invite' || authTab === 'register') && (
|
{(authTab === 'invite' || authTab === 'register') && (
|
||||||
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
||||||
|
{authTab === 'register' && registrationHint && (
|
||||||
|
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHint}</p>
|
||||||
|
)}
|
||||||
{authTab === 'invite' && (
|
{authTab === 'invite' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">Einladungscode *</label>
|
<label className="block text-muted mb-1">Einladungscode *</label>
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
Building2,
|
Building2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Link2,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Organization,
|
Organization,
|
||||||
|
RegistrationMode,
|
||||||
SharingMode,
|
SharingMode,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
User,
|
User,
|
||||||
@@ -78,6 +82,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||||
const [inviteOrg, setInviteOrg] = useState('');
|
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<string | null>(null);
|
||||||
|
const [mailInviteTo, setMailInviteTo] = useState('');
|
||||||
|
const [mailInviteBusy, setMailInviteBusy] = useState(false);
|
||||||
|
|
||||||
// --- Organizations State ---
|
// --- Organizations State ---
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -128,6 +138,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
setSettings({
|
setSettings({
|
||||||
sharingMode: sys.sharingMode || 'organizations',
|
sharingMode: sys.sharingMode || 'organizations',
|
||||||
allowRegistration: Boolean(sys.allowRegistration),
|
allowRegistration: Boolean(sys.allowRegistration),
|
||||||
|
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
|
||||||
|
registrationDomains: sys.registrationDomains || '',
|
||||||
|
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
|
||||||
instanceName: sys.instanceName || 'MindSift',
|
instanceName: sys.instanceName || 'MindSift',
|
||||||
smtpHost: sys.smtpHost || '',
|
smtpHost: sys.smtpHost || '',
|
||||||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||||
@@ -197,15 +210,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
const inv = await api.adminCreateInvite({
|
const inv = await api.adminCreateInvite({
|
||||||
email: inviteEmail.trim(),
|
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
organizationId: inviteOrg || undefined,
|
organizationId: inviteOrg || undefined,
|
||||||
|
maxUses: inviteMaxUses,
|
||||||
});
|
});
|
||||||
setInvites((prev) => [inv, ...prev]);
|
setInvites((prev) => [inv, ...prev]);
|
||||||
setInviteEmail('');
|
setInviteEmail('');
|
||||||
setStatusMsg({
|
setStatusMsg({
|
||||||
type: 'ok',
|
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) {
|
} catch (err: any) {
|
||||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||||||
@@ -221,12 +238,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyInviteCode = (inv: UserInvite) => {
|
// Copies the code or the link that opens the sign-up form with the code filled in
|
||||||
navigator.clipboard.writeText(inv.code);
|
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);
|
setCopiedInviteId(inv.id);
|
||||||
setTimeout(() => setCopiedInviteId(null), 2000);
|
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) => {
|
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
try {
|
try {
|
||||||
@@ -273,7 +323,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const handleDeleteUser = async (u: User) => {
|
const handleDeleteUser = async (u: User) => {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!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;
|
return;
|
||||||
@@ -642,21 +694,35 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
|
<span>Einladung erzeugen (gültig für 7 Tage)</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<label className="block text-muted mb-1">Wie oft verwendbar</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={inviteMaxUses}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<label className="block text-muted mb-1">
|
<label className="block text-muted mb-1">
|
||||||
Gebundene E-Mail (optional)
|
Gebundene E-Mail (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={inviteEmail}
|
value={inviteMaxUses > 1 ? '' : inviteEmail}
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
placeholder="Leer lassen für beliebige E-Mail"
|
disabled={inviteMaxUses > 1}
|
||||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -697,7 +763,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
||||||
>
|
>
|
||||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Einladungscode generieren</span>
|
<span>{inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : 'Einladungscode generieren'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -709,12 +775,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
Aktive & bisherige Einladungscodes:
|
Aktive & bisherige Einladungscodes:
|
||||||
</p>
|
</p>
|
||||||
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
||||||
{invites.map((inv) => (
|
{invites.map((inv) => {
|
||||||
<div
|
const maxUses = inv.maxUses || 1;
|
||||||
key={inv.id}
|
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
|
||||||
className="p-2 flex flex-wrap items-center justify-between gap-2"
|
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
|
||||||
>
|
const copied = copiedInviteId === inv.id;
|
||||||
<div className="flex items-center gap-2.5">
|
return (
|
||||||
|
<div key={inv.id} className="p-2 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 min-w-0">
|
||||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||||
{inv.code}
|
{inv.code}
|
||||||
</code>
|
</code>
|
||||||
@@ -722,27 +791,48 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||||
|
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
|
||||||
</span>
|
</span>
|
||||||
{inv.usedBy ? (
|
{open ? (
|
||||||
<span className="badge-neutral text-[10px]">
|
<span className="badge-ok text-[10px]">Offen</span>
|
||||||
Eingelöst
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="badge-ok text-[10px]">
|
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
|
||||||
Offen
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{!inv.usedBy && (
|
{open && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopyInviteCode(inv)}
|
type="button"
|
||||||
|
onClick={() => handleCopyInvite(inv, 'link')}
|
||||||
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
|
title={inviteLink(inv)}
|
||||||
|
>
|
||||||
|
<Link2 className="w-3 h-3" />
|
||||||
|
<span>{copied && copiedWhat === 'link' ? 'Kopiert!' : 'Link kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyInvite(inv, 'code')}
|
||||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
|
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMailInviteId(mailInviteId === inv.id ? null : inv.id);
|
||||||
|
setMailInviteTo(inv.email || '');
|
||||||
|
}}
|
||||||
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
|
title="Einladung per E-Mail verschicken"
|
||||||
|
>
|
||||||
|
<Mail className="w-3 h-3" />
|
||||||
|
<span>Per E-Mail</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteInvite(inv.id)}
|
onClick={() => handleDeleteInvite(inv.id)}
|
||||||
@@ -753,19 +843,97 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{mailInviteId === inv.id && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSendInvite(inv);
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={mailInviteTo}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mailInviteBusy}
|
||||||
|
className="btn-primary h-8 px-3 text-xs font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
<span>{mailInviteBusy ? 'Sende…' : 'Einladung senden'}</span>
|
||||||
|
</button>
|
||||||
|
{maxUses === 1 && !inv.email && (
|
||||||
|
<span className="w-full text-[11px] text-faint">Die Einladung gilt danach nur noch für diese Adresse.</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations waiting for approval */}
|
||||||
|
{users.some((u) => u.pendingApproval) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})
|
||||||
|
</h3>
|
||||||
|
<div className="border border-status-warn/40 divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||||
|
{users
|
||||||
|
.filter((u) => u.pendingApproval)
|
||||||
|
.map((u) => (
|
||||||
|
<div key={u.id} className="p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-main truncate">{u.displayName}</p>
|
||||||
|
<p className="text-muted text-[11px] truncate">
|
||||||
|
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleApproveUser(u)}
|
||||||
|
className="btn-primary h-11 sm:h-8 px-3 text-xs font-semibold flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteUser(u)}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 hover:text-status-err"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Users List */}
|
{/* Users List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-bold text-main">
|
<h3 className="font-bold text-main">
|
||||||
Registrierte Benutzer ({users.length})
|
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||||
{users.map((u) => (
|
{users.filter((u) => !u.pendingApproval).map((u) => (
|
||||||
<div key={u.id} className="p-3 space-y-2">
|
<div key={u.id} className="p-3 space-y-2">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -1136,27 +1304,55 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Public Registration Toggle Switch */}
|
{/* Who may register without an invite */}
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
{(() => {
|
||||||
<div className="pr-4">
|
const mode = settings.registrationMode || 'closed';
|
||||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
const locked = Boolean(settings.registrationConfiguredViaEnv);
|
||||||
|
const needsDomains = mode === 'domain' || mode === 'domain+approval';
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-main">Registrierung ohne Einladung</p>
|
||||||
<p className="text-[11px] text-muted mt-0.5">
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
|
||||||
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
value={mode}
|
||||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
disabled={locked}
|
||||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
|
||||||
</span>
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
|
||||||
<ToggleSwitch
|
>
|
||||||
checked={settings.allowRegistration}
|
<option value="closed">Geschlossen – nur mit Einladung</option>
|
||||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
|
||||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
<option value="domain">Nur bestimmte E-Mail-Endungen – sofort aktiv</option>
|
||||||
|
<option value="domain+approval">Endungen sofort, alle anderen nach Freigabe</option>
|
||||||
|
<option value="open">Offen – jeder, sofort aktiv</option>
|
||||||
|
</select>
|
||||||
|
{needsDomains && (
|
||||||
|
<label className="block text-[11px] text-muted">
|
||||||
|
Erlaubte E-Mail-Endungen (durch Komma getrennt, <code className="font-mono">*.firma.de</code> auch für Subdomains)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={locked}
|
||||||
|
value={settings.registrationDomains || ''}
|
||||||
|
onChange={(e) => setSettings((prev) => ({ ...prev, registrationDomains: e.target.value }))}
|
||||||
|
onBlur={() => handleSaveSystemSettings()}
|
||||||
|
placeholder="firma.de, familie-dach.de"
|
||||||
|
className="block w-full mt-1 h-11 sm:h-8 px-2.5 input-field font-mono text-base sm:text-xs disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{locked && (
|
||||||
|
<p className="text-[11px] text-status-warn">
|
||||||
|
Über die Umgebung festgelegt (<code className="font-mono">REGISTRATION_MODE</code> /{' '}
|
||||||
|
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
+13
-3
@@ -271,12 +271,13 @@ export const api = {
|
|||||||
password: string,
|
password: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
inviteCode?: string
|
inviteCode?: string
|
||||||
): Promise<{ token: string; user: User }> {
|
): Promise<{ token?: string; user?: User; pending?: boolean; message?: string }> {
|
||||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
const res = await request<{ token?: string; user?: User; pending?: boolean; message?: string }>('/api/v1/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
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;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1121,6 +1122,7 @@ export const api = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
role: string;
|
role: string;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
|
maxUses?: number;
|
||||||
}): Promise<UserInvite> {
|
}): Promise<UserInvite> {
|
||||||
return request<UserInvite>('/api/v1/admin/invites', {
|
return request<UserInvite>('/api/v1/admin/invites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1128,6 +1130,14 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async adminSendInvite(id: string, email: string): Promise<UserInvite> {
|
||||||
|
return request<UserInvite>(`/api/v1/admin/invites/${id}/send`, { method: 'POST', body: JSON.stringify({ email }) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async adminApproveUser(id: string): Promise<User> {
|
||||||
|
return request<User>(`/api/v1/admin/users/${id}/approve`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
async adminDeleteInvite(id: string): Promise<void> {
|
async adminDeleteInvite(id: string): Promise<void> {
|
||||||
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
|||||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
export type UserRole = 'admin' | 'member';
|
export type UserRole = 'admin' | 'member';
|
||||||
|
|
||||||
|
export type RegistrationMode = 'closed' | 'approval' | 'domain' | 'domain+approval' | 'open';
|
||||||
|
|
||||||
export interface AuthStatus {
|
export interface AuthStatus {
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
allowRegistration: 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;
|
instanceName: string;
|
||||||
demoMode?: boolean;
|
demoMode?: boolean;
|
||||||
sharingMode?: SharingMode;
|
sharingMode?: SharingMode;
|
||||||
@@ -94,6 +99,8 @@ export interface User {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
// Registered and waiting for an admin's approval (admin user list)
|
||||||
|
pendingApproval?: boolean;
|
||||||
alarmChannels?: string[];
|
alarmChannels?: string[];
|
||||||
alarmEmail?: string;
|
alarmEmail?: string;
|
||||||
discordWebhookUrl?: string;
|
discordWebhookUrl?: string;
|
||||||
@@ -115,6 +122,9 @@ export interface UserInvite {
|
|||||||
usedBy?: string;
|
usedBy?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
// A link for several people: allowed and used redemptions
|
||||||
|
maxUses: number;
|
||||||
|
useCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubTask {
|
export interface SubTask {
|
||||||
@@ -250,6 +260,10 @@ export interface SystemSettings {
|
|||||||
smtpSkipVerify?: boolean;
|
smtpSkipVerify?: boolean;
|
||||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||||
smtpConfiguredViaEnv?: boolean;
|
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
|
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
|
||||||
backupEnabled?: boolean;
|
backupEnabled?: boolean;
|
||||||
backupTime?: string;
|
backupTime?: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- ORGANIZATIONS & TEAMS ---
|
// --- ORGANIZATIONS & TEAMS ---
|
||||||
@@ -93,6 +94,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pending := s.store.PendingRegistrationIDs()
|
||||||
|
for i := range users {
|
||||||
|
users[i].PendingApproval = pending[users[i].ID]
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, users)
|
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 {
|
if req.IsActive != nil && !*req.IsActive {
|
||||||
s.wsHub.DisconnectUser(targetUserID)
|
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)
|
writeJSON(w, http.StatusOK, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +255,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.removeUserBackups(targetUserID)
|
s.removeUserBackups(targetUserID)
|
||||||
|
s.store.ClearPendingRegistration(targetUserID)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
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"`
|
Role string `json:"role"`
|
||||||
OrganizationID string `json:"organizationId"`
|
OrganizationID string `json:"organizationId"`
|
||||||
ValidDays int `json:"validDays"`
|
ValidDays int `json:"validDays"`
|
||||||
|
MaxUses int `json:"maxUses"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
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") {
|
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
||||||
req.Role = "member"
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
@@ -312,6 +322,16 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
|||||||
out.SMTPPassSet = out.SMTPPass != ""
|
out.SMTPPassSet = out.SMTPPass != ""
|
||||||
out.SMTPPass = ""
|
out.SMTPPass = ""
|
||||||
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
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
|
return &out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +347,17 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
settings.SMTPPass = cur.SMTPPass
|
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()
|
previousMode := s.store.SharingMode()
|
||||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
count, _ := s.store.CountUsers()
|
count, _ := s.store.CountUsers()
|
||||||
sys, _ := s.store.GetSystemSettings()
|
sys, _ := s.store.GetSystemSettings()
|
||||||
allowReg := false
|
policy := s.registrationPolicy()
|
||||||
instanceName := "MindSift"
|
instanceName := "MindSift"
|
||||||
if sys != nil {
|
if sys != nil {
|
||||||
allowReg = sys.AllowRegistration
|
|
||||||
if sys.InstanceName != "" {
|
if sys.InstanceName != "" {
|
||||||
instanceName = sys.InstanceName
|
instanceName = sys.InstanceName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status := map[string]interface{}{
|
status := map[string]interface{}{
|
||||||
"needsSetup": count == 0,
|
"needsSetup": count == 0,
|
||||||
"allowRegistration": allowReg,
|
// 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,
|
"instanceName": instanceName,
|
||||||
"demoMode": s.cfg.DemoMode,
|
"demoMode": s.cfg.DemoMode,
|
||||||
"sharingMode": s.store.SharingMode(),
|
"sharingMode": s.store.SharingMode(),
|
||||||
@@ -131,6 +134,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
sys.InstanceName = strings.TrimSpace(req.InstanceName)
|
||||||
}
|
}
|
||||||
sys.AllowRegistration = req.AllowRegistration
|
sys.AllowRegistration = req.AllowRegistration
|
||||||
|
sys.RegistrationMode = storage.RegistrationClosed
|
||||||
|
if req.AllowRegistration {
|
||||||
|
sys.RegistrationMode = storage.RegistrationOpen
|
||||||
|
}
|
||||||
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode)
|
||||||
if err := s.store.UpdateSystemSettings(sys); err != nil {
|
if err := s.store.UpdateSystemSettings(sys); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error())
|
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()
|
count, _ := s.store.CountUsers()
|
||||||
role := "member"
|
role := "member"
|
||||||
var validInvite *domain.UserInvite
|
var validInvite *domain.UserInvite
|
||||||
|
pending := false // registered without invite while approval is needed
|
||||||
|
|
||||||
if strings.TrimSpace(req.InviteCode) != "" {
|
if strings.TrimSpace(req.InviteCode) != "" {
|
||||||
inv, err := s.store.GetUserInviteByCode(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 {
|
} else if count == 0 {
|
||||||
role = "admin"
|
role = "admin"
|
||||||
} else {
|
} else {
|
||||||
sysSettings, _ := s.store.GetSystemSettings()
|
var err error
|
||||||
if sysSettings == nil || !sysSettings.AllowRegistration {
|
pending, err = s.registrationPolicy().registrationOutcome(req.Email)
|
||||||
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
|
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
|
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)
|
token, err := s.startSession(r, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||||
@@ -310,6 +343,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.loginGuard.recordSuccess(account, ip)
|
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 {
|
if !user.IsActive {
|
||||||
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
|
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")
|
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
||||||
srv, store, _ := setupTestServer(t)
|
srv, store, _ := setupTestServer(t)
|
||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
inv, err := store.CreateUserInvite("", "", "member", "", 7)
|
inv, err := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
|
|||||||
func TestInviteReleasedWhenAccountFails(t *testing.T) {
|
func TestInviteReleasedWhenAccountFails(t *testing.T) {
|
||||||
srv, store, _ := setupTestServer(t)
|
srv, store, _ := setupTestServer(t)
|
||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
|
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||||
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
|
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
|
||||||
body := func(email string) string {
|
body := func(email string) string {
|
||||||
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
|
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,10 +290,12 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
|
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
|
||||||
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
|
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
|
||||||
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
|
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.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||||
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
|
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
|
// Server-Admin Strictly Protected Routes
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ type Config struct {
|
|||||||
// from it, never from the request's Host header.
|
// from it, never from the request's Host header.
|
||||||
BaseURLSet bool
|
BaseURLSet bool
|
||||||
AllowRegistration bool
|
AllowRegistration 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
|
LogLevel string
|
||||||
Debug bool
|
Debug bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
@@ -234,6 +240,8 @@ func Load() *Config {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||||
AllowRegistration: allowReg,
|
AllowRegistration: allowReg,
|
||||||
|
RegistrationMode: registrationMode(allowReg),
|
||||||
|
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
DemoMode: demoMode,
|
DemoMode: demoMode,
|
||||||
@@ -326,3 +334,18 @@ func demoResetMinutes() int {
|
|||||||
}
|
}
|
||||||
return n
|
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 ""
|
||||||
|
}
|
||||||
@@ -135,6 +135,8 @@ type User struct {
|
|||||||
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
|
||||||
OrganizationID string `json:"organizationId,omitempty"`
|
OrganizationID string `json:"organizationId,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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)
|
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
|
||||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -216,6 +218,9 @@ type UserInvite struct {
|
|||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
ExpiresAt string `json:"expiresAt"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
UsedBy string `json:"usedBy,omitempty"`
|
UsedBy string `json:"usedBy,omitempty"`
|
||||||
|
// 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"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +356,14 @@ type TeamMember struct {
|
|||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
SharingMode string `json:"sharingMode"` // private | organizations | open
|
SharingMode string `json:"sharingMode"` // private | organizations | open
|
||||||
|
// AllowRegistration mirrors RegistrationMode == "open" (older clients and the setup wizard)
|
||||||
AllowRegistration bool `json:"allowRegistration"`
|
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"`
|
InstanceName string `json:"instanceName"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort string `json:"smtpPort"`
|
SMTPPort string `json:"smtpPort"`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ var migrations = []migration{
|
|||||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||||
{8, "Journal", (*Store).migrateJournal},
|
{8, "Journal", (*Store).migrateJournal},
|
||||||
{9, "Web-Push", (*Store).migratePush},
|
{9, "Web-Push", (*Store).migratePush},
|
||||||
|
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -138,6 +138,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
|||||||
settings.SharingMode = v
|
settings.SharingMode = v
|
||||||
case "allow_registration":
|
case "allow_registration":
|
||||||
settings.AllowRegistration = (v == "true" || v == "1")
|
settings.AllowRegistration = (v == "true" || v == "1")
|
||||||
|
case "registration_mode":
|
||||||
|
settings.RegistrationMode = NormalizeRegistrationMode(v)
|
||||||
|
case "registration_domains":
|
||||||
|
settings.RegistrationDomains = v
|
||||||
case "instance_name":
|
case "instance_name":
|
||||||
settings.InstanceName = v
|
settings.InstanceName = v
|
||||||
case "smtp_host":
|
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
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
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"
|
regStr := "false"
|
||||||
if settings.AllowRegistration {
|
if settings.AllowRegistration {
|
||||||
regStr = "true"
|
regStr = "true"
|
||||||
@@ -221,7 +243,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
|||||||
('allow_private_webhooks', ?),
|
('allow_private_webhooks', ?),
|
||||||
('backup_enabled', ?),
|
('backup_enabled', ?),
|
||||||
('backup_time', ?),
|
('backup_time', ?),
|
||||||
('backup_retention_days', ?)
|
('backup_retention_days', ?),
|
||||||
|
('registration_mode', ?),
|
||||||
|
('registration_domains', ?)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
settings.SharingMode,
|
settings.SharingMode,
|
||||||
regStr,
|
regStr,
|
||||||
@@ -238,6 +262,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
|||||||
strconv.FormatBool(settings.BackupEnabled),
|
strconv.FormatBool(settings.BackupEnabled),
|
||||||
settings.BackupTime,
|
settings.BackupTime,
|
||||||
strconv.Itoa(settings.BackupRetentionDays),
|
strconv.Itoa(settings.BackupRetentionDays),
|
||||||
|
settings.RegistrationMode,
|
||||||
|
settings.RegistrationDomains,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
+53
-22
@@ -327,13 +327,25 @@ func (s *Store) RevokeAllUserTokens(userID string) error {
|
|||||||
|
|
||||||
// --- USER INVITES ---
|
// --- 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" {
|
if role != "admin" && role != "member" {
|
||||||
role = "member"
|
role = "member"
|
||||||
}
|
}
|
||||||
if validDays <= 0 || validDays > 365 {
|
if validDays <= 0 || validDays > 365 {
|
||||||
validDays = 7
|
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()
|
code := newInviteCode()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
inv := &domain.UserInvite{
|
inv := &domain.UserInvite{
|
||||||
@@ -345,11 +357,12 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
|||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
ExpiresAt: now.AddDate(0, 0, validDays).Format(time.RFC3339),
|
||||||
CreatedAt: now.Format(time.RFC3339),
|
CreatedAt: now.Format(time.RFC3339),
|
||||||
|
MaxUses: maxUses,
|
||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at)
|
`INSERT INTO user_invites (id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, 0)`,
|
||||||
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt,
|
inv.ID, inv.Code, inv.Email, inv.Role, inv.OrganizationID, inv.CreatedBy, inv.ExpiresAt, inv.CreatedAt, inv.MaxUses,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -359,7 +372,8 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
|
|||||||
|
|
||||||
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
||||||
rows, err := s.db.Query(
|
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
|
FROM user_invites i
|
||||||
LEFT JOIN organizations o ON o.id = i.organization_id
|
LEFT JOIN organizations o ON o.id = i.organization_id
|
||||||
ORDER BY i.created_at DESC`,
|
ORDER BY i.created_at DESC`,
|
||||||
@@ -372,7 +386,7 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
|
|||||||
invites := make([]domain.UserInvite, 0)
|
invites := make([]domain.UserInvite, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var inv domain.UserInvite
|
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)
|
invites = append(invites, inv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,17 +403,28 @@ func newInviteCode() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
|
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
|
var inv domain.UserInvite
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at
|
`SELECT id, code, email, role, organization_id, created_by, expires_at, used_by, created_at, max_uses, use_count
|
||||||
FROM user_invites WHERE UPPER(code) = ?`,
|
FROM user_invites WHERE `+where,
|
||||||
code,
|
arg,
|
||||||
).Scan(&inv.ID, &inv.Code, &inv.Email, &inv.Role, &inv.OrganizationID, &inv.CreatedBy, &inv.ExpiresAt, &inv.UsedBy, &inv.CreatedAt)
|
).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 {
|
if err != nil {
|
||||||
return nil, errors.New("Ungültiger Einladungscode")
|
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")
|
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) {
|
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
|
return &inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
|
// ClaimUserInvite reserves one use of an invite before the account is created, so registrations with
|
||||||
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
|
// the same code can never use it more often than allowed, even at the same moment (FLAW-40). The
|
||||||
// ReleaseUserInvite.
|
// returned marker goes to FinishUserInvite or ReleaseUserInvite.
|
||||||
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
|
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
|
||||||
claim := domain.NewID("claim")
|
res, err := s.db.Exec(`UPDATE user_invites SET use_count = use_count + 1 WHERE id = ? AND use_count < max_uses`, inviteID)
|
||||||
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if n, _ := res.RowsAffected(); n != 1 {
|
if n, _ := res.RowsAffected(); n != 1 {
|
||||||
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
|
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 {
|
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
|
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) {
|
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 {
|
func (s *Store) DeleteUserInvite(inviteID string) error {
|
||||||
|
|||||||
Reference in new issue
Block a user