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
+1077
-142
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Shield,
|
||||
@@ -43,13 +43,37 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const [createSampleData, setCreateSampleData] = useState(false);
|
||||
|
||||
// --- Login / Invite / Register State ---
|
||||
// An invite link (?invite=CODE) opens the invite form with the code filled in
|
||||
const [linkInviteCode] = useState(() => new URLSearchParams(window.location.search).get('invite')?.trim().toUpperCase() || '');
|
||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||
resetToken ? 'reset' : 'login'
|
||||
resetToken ? 'reset' : linkInviteCode ? 'invite' : 'login'
|
||||
);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState(linkInviteCode);
|
||||
// The code is in the form now; it should not stay in the address bar (history, screenshots)
|
||||
useEffect(() => {
|
||||
if (!linkInviteCode) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('invite');
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
|
||||
}, [linkInviteCode]);
|
||||
// Hint on the sign-up form: which addresses get in, and whether an admin approves first
|
||||
const registrationHint = (() => {
|
||||
const domains = (authStatus.registrationDomains || []).map((d) => '@' + d.replace(/^\*\./, '')).join(', ');
|
||||
switch (authStatus.registrationMode) {
|
||||
case 'approval':
|
||||
return 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
|
||||
case 'domain':
|
||||
return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : '';
|
||||
case 'domain+approval':
|
||||
return domains
|
||||
? `Mit Adressen von ${domains} geht es sofort, andere Konten gibt ein Administrator frei.`
|
||||
: 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -167,6 +191,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
try {
|
||||
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
||||
const res = await api.register(email.trim(), password, displayName.trim(), code);
|
||||
if (res.pending || !res.user) {
|
||||
// Waiting for approval: back to the login form with the explanation
|
||||
setNotice(res.message || 'Dein Konto wartet auf die Freigabe durch einen Administrator.');
|
||||
setPassword('');
|
||||
setAuthTab('login');
|
||||
return;
|
||||
}
|
||||
onAuthenticated(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen');
|
||||
@@ -687,6 +718,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
{(authTab === 'invite' || authTab === 'register') && (
|
||||
<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' && (
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Einladungscode *</label>
|
||||
|
||||
@@ -15,9 +15,13 @@ import {
|
||||
UserPlus,
|
||||
Building2,
|
||||
Pencil,
|
||||
Link2,
|
||||
Clock,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
RegistrationMode,
|
||||
SharingMode,
|
||||
SystemSettings,
|
||||
User,
|
||||
@@ -78,6 +82,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteOrg, setInviteOrg] = useState('');
|
||||
// A link for several people (1 = classic single-use code)
|
||||
const [inviteMaxUses, setInviteMaxUses] = useState(1);
|
||||
// "Per E-Mail einladen": which invite the address field is open for
|
||||
const [mailInviteId, setMailInviteId] = useState<string | null>(null);
|
||||
const [mailInviteTo, setMailInviteTo] = useState('');
|
||||
const [mailInviteBusy, setMailInviteBusy] = useState(false);
|
||||
|
||||
// --- Organizations State ---
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@@ -128,6 +138,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'organizations',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
|
||||
registrationDomains: sys.registrationDomains || '',
|
||||
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||
@@ -197,15 +210,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
organizationId: inviteOrg || undefined,
|
||||
maxUses: inviteMaxUses,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||
text:
|
||||
inv.maxUses > 1
|
||||
? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).`
|
||||
: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||||
@@ -221,12 +238,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteCode = (inv: UserInvite) => {
|
||||
navigator.clipboard.writeText(inv.code);
|
||||
// Copies the code or the link that opens the sign-up form with the code filled in
|
||||
const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`;
|
||||
const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code');
|
||||
const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => {
|
||||
navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code);
|
||||
setCopiedWhat(what);
|
||||
setCopiedInviteId(inv.id);
|
||||
setTimeout(() => setCopiedInviteId(null), 2000);
|
||||
};
|
||||
|
||||
const handleSendInvite = async (inv: UserInvite) => {
|
||||
if (!mailInviteTo.trim()) return;
|
||||
setMailInviteBusy(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim());
|
||||
setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i)));
|
||||
setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` });
|
||||
setMailInviteId(null);
|
||||
setMailInviteTo('');
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' });
|
||||
} finally {
|
||||
setMailInviteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Registrations waiting for approval (registration mode "Mit Freigabe")
|
||||
const handleApproveUser = async (u: User) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminApproveUser(u.id);
|
||||
setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item)));
|
||||
setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
@@ -273,7 +323,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const handleDeleteUser = async (u: User) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||
u.pendingApproval
|
||||
? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.`
|
||||
: `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -642,21 +694,35 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
value={inviteMaxUses > 1 ? '' : inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Leer lassen für beliebige E-Mail"
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
disabled={inviteMaxUses > 1}
|
||||
placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'}
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -709,63 +775,165 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
Aktive & bisherige Einladungscodes:
|
||||
</p>
|
||||
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
||||
{invites.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="p-2 flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||
{inv.code}
|
||||
</code>
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
</span>
|
||||
{inv.usedBy ? (
|
||||
<span className="badge-neutral text-[10px]">
|
||||
Eingelöst
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-ok text-[10px]">
|
||||
Offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{invites.map((inv) => {
|
||||
const maxUses = inv.maxUses || 1;
|
||||
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
|
||||
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
|
||||
const copied = copiedInviteId === inv.id;
|
||||
return (
|
||||
<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">
|
||||
{inv.code}
|
||||
</code>
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
|
||||
</span>
|
||||
{open ? (
|
||||
<span className="badge-ok text-[10px]">Offen</span>
|
||||
) : (
|
||||
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!inv.usedBy && (
|
||||
<button
|
||||
onClick={() => handleCopyInviteCode(inv)}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{open && (
|
||||
<>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
|
||||
</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
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Einladung löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{mailInviteId === inv.id && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void handleSendInvite(inv);
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
|
||||
</button>
|
||||
<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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Einladung löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-main">
|
||||
Registrierte Benutzer ({users.length})
|
||||
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
|
||||
</h3>
|
||||
<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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -1136,27 +1304,55 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
||||
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Who may register without an invite */}
|
||||
{(() => {
|
||||
const mode = settings.registrationMode || 'closed';
|
||||
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">
|
||||
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
|
||||
immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={mode}
|
||||
disabled={locked}
|
||||
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
|
||||
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
|
||||
>
|
||||
<option value="closed">Geschlossen – nur mit Einladung</option>
|
||||
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
|
||||
<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>
|
||||
</fieldset>
|
||||
|
||||
+13
-3
@@ -271,12 +271,13 @@ export const api = {
|
||||
password: string,
|
||||
displayName: string,
|
||||
inviteCode?: string
|
||||
): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
|
||||
): Promise<{ token?: string; user?: User; pending?: boolean; message?: string }> {
|
||||
const res = await request<{ token?: string; user?: User; pending?: boolean; message?: string }>('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
|
||||
});
|
||||
setToken(res.token);
|
||||
// Waiting for approval: no session yet
|
||||
if (res.token) setToken(res.token);
|
||||
return res;
|
||||
},
|
||||
|
||||
@@ -1121,6 +1122,7 @@ export const api = {
|
||||
email?: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
maxUses?: number;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
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> {
|
||||
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 UserRole = 'admin' | 'member';
|
||||
|
||||
export type RegistrationMode = 'closed' | 'approval' | 'domain' | 'domain+approval' | 'open';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
// Registering without an invite and the e-mail domains of the domain modes (hint on the form)
|
||||
registrationMode?: RegistrationMode;
|
||||
registrationDomains?: string[];
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
sharingMode?: SharingMode;
|
||||
@@ -94,6 +99,8 @@ export interface User {
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
// Registered and waiting for an admin's approval (admin user list)
|
||||
pendingApproval?: boolean;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
@@ -115,6 +122,9 @@ export interface UserInvite {
|
||||
usedBy?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
// A link for several people: allowed and used redemptions
|
||||
maxUses: number;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
@@ -250,6 +260,10 @@ export interface SystemSettings {
|
||||
smtpSkipVerify?: boolean;
|
||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
registrationMode?: RegistrationMode;
|
||||
registrationDomains?: string;
|
||||
// REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true) fix it: shown, not editable
|
||||
registrationConfiguredViaEnv?: boolean;
|
||||
// Automatic backups: daily time (HH:MM, server time zone) and days to keep them
|
||||
backupEnabled?: boolean;
|
||||
backupTime?: string;
|
||||
|
||||
Reference in new issue
Block a user