diff --git a/client/src/App.tsx b/client/src/App.tsx index 1c09ed7..47c2f8d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -107,7 +107,7 @@ export default function App() { } /> } /> - } /> + } /> apiClient.post('/auth/register', data).then(r => r.data), + register: (data: { username: string; email: string; password: string; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data), + validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data), login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data), me: () => apiClient.get('/auth/me').then(r => r.data), updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data), @@ -135,6 +136,9 @@ export const adminApi = { updateAddon: (id: number | string, data: Record) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data), checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data), + listInvites: () => apiClient.get('/admin/invites').then(r => r.data), + createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), + deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), } export const addonsApi = { diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 89dd817..faf5d07 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -274,6 +274,24 @@ const de: Record = { 'admin.toast.createError': 'Fehler beim Erstellen des Benutzers', 'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich', 'admin.createUser': 'Benutzer anlegen', + 'admin.invite.title': 'Einladungslinks', + 'admin.invite.subtitle': 'Einmal-Links für die Registrierung erstellen', + 'admin.invite.create': 'Link erstellen', + 'admin.invite.createAndCopy': 'Erstellen & kopieren', + 'admin.invite.empty': 'Noch keine Einladungslinks erstellt', + 'admin.invite.maxUses': 'Max. Nutzungen', + 'admin.invite.expiry': 'Gültig für', + 'admin.invite.uses': 'genutzt', + 'admin.invite.expiresAt': 'läuft ab am', + 'admin.invite.createdBy': 'von', + 'admin.invite.active': 'Aktiv', + 'admin.invite.expired': 'Abgelaufen', + 'admin.invite.usedUp': 'Aufgebraucht', + 'admin.invite.copied': 'Einladungslink in Zwischenablage kopiert', + 'admin.invite.copyLink': 'Link kopieren', + 'admin.invite.deleted': 'Einladungslink gelöscht', + 'admin.invite.createError': 'Fehler beim Erstellen des Einladungslinks', + 'admin.invite.deleteError': 'Fehler beim Löschen des Einladungslinks', 'admin.tabs.settings': 'Einstellungen', 'admin.allowRegistration': 'Registrierung erlauben', 'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 803624e..b81aa8e 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -274,6 +274,24 @@ const en: Record = { 'admin.toast.createError': 'Failed to create user', 'admin.toast.fieldsRequired': 'Username, email and password are required', 'admin.createUser': 'Create User', + 'admin.invite.title': 'Invite Links', + 'admin.invite.subtitle': 'Create one-time registration links', + 'admin.invite.create': 'Create Link', + 'admin.invite.createAndCopy': 'Create & Copy', + 'admin.invite.empty': 'No invite links created yet', + 'admin.invite.maxUses': 'Max. Uses', + 'admin.invite.expiry': 'Expires after', + 'admin.invite.uses': 'used', + 'admin.invite.expiresAt': 'expires', + 'admin.invite.createdBy': 'by', + 'admin.invite.active': 'Active', + 'admin.invite.expired': 'Expired', + 'admin.invite.usedUp': 'Used up', + 'admin.invite.copied': 'Invite link copied to clipboard', + 'admin.invite.copyLink': 'Copy link', + 'admin.invite.deleted': 'Invite link deleted', + 'admin.invite.createError': 'Failed to create invite link', + 'admin.invite.deleteError': 'Failed to delete invite link', 'admin.tabs.settings': 'Settings', 'admin.allowRegistration': 'Allow Registration', 'admin.allowRegistrationHint': 'New users can register themselves', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index e1cab9e..b7b1340 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -272,6 +272,24 @@ const es: Record = { 'admin.toast.createError': 'No se pudo crear el usuario', 'admin.toast.fieldsRequired': 'Usuario, correo y contraseña son obligatorios', 'admin.createUser': 'Crear usuario', + 'admin.invite.title': 'Enlaces de invitación', + 'admin.invite.subtitle': 'Crear enlaces de registro de un solo uso', + 'admin.invite.create': 'Crear enlace', + 'admin.invite.createAndCopy': 'Crear y copiar', + 'admin.invite.empty': 'No se han creado enlaces de invitación', + 'admin.invite.maxUses': 'Usos máx.', + 'admin.invite.expiry': 'Expira después de', + 'admin.invite.uses': 'usado(s)', + 'admin.invite.expiresAt': 'expira el', + 'admin.invite.createdBy': 'por', + 'admin.invite.active': 'Activo', + 'admin.invite.expired': 'Expirado', + 'admin.invite.usedUp': 'Agotado', + 'admin.invite.copied': 'Enlace de invitación copiado', + 'admin.invite.copyLink': 'Copiar enlace', + 'admin.invite.deleted': 'Enlace de invitación eliminado', + 'admin.invite.createError': 'Error al crear el enlace', + 'admin.invite.deleteError': 'Error al eliminar el enlace', 'admin.tabs.settings': 'Ajustes', 'admin.allowRegistration': 'Permitir el registro', 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index ef5be0a..dc772e4 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -274,6 +274,24 @@ const fr: Record = { 'admin.toast.createError': 'Échec de la création de l\'utilisateur', 'admin.toast.fieldsRequired': 'Le nom d\'utilisateur, l\'e-mail et le mot de passe sont requis', 'admin.createUser': 'Créer un utilisateur', + 'admin.invite.title': 'Liens d\'invitation', + 'admin.invite.subtitle': 'Créer des liens d\'inscription à usage unique', + 'admin.invite.create': 'Créer un lien', + 'admin.invite.createAndCopy': 'Créer et copier', + 'admin.invite.empty': 'Aucun lien d\'invitation créé', + 'admin.invite.maxUses': 'Utilisations max.', + 'admin.invite.expiry': 'Expire après', + 'admin.invite.uses': 'utilisé(s)', + 'admin.invite.expiresAt': 'expire le', + 'admin.invite.createdBy': 'par', + 'admin.invite.active': 'Actif', + 'admin.invite.expired': 'Expiré', + 'admin.invite.usedUp': 'Épuisé', + 'admin.invite.copied': 'Lien d\'invitation copié', + 'admin.invite.copyLink': 'Copier le lien', + 'admin.invite.deleted': 'Lien d\'invitation supprimé', + 'admin.invite.createError': 'Erreur lors de la création du lien', + 'admin.invite.deleteError': 'Erreur lors de la suppression du lien', 'admin.tabs.settings': 'Paramètres', 'admin.allowRegistration': 'Autoriser les inscriptions', 'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 9b2591b..675f1b5 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -274,6 +274,24 @@ const nl: Record = { 'admin.toast.createError': 'Gebruiker aanmaken mislukt', 'admin.toast.fieldsRequired': 'Gebruikersnaam, e-mail en wachtwoord zijn verplicht', 'admin.createUser': 'Gebruiker aanmaken', + 'admin.invite.title': 'Uitnodigingslinks', + 'admin.invite.subtitle': 'Eenmalige registratielinks aanmaken', + 'admin.invite.create': 'Link aanmaken', + 'admin.invite.createAndCopy': 'Aanmaken en kopiëren', + 'admin.invite.empty': 'Nog geen uitnodigingslinks aangemaakt', + 'admin.invite.maxUses': 'Max. gebruik', + 'admin.invite.expiry': 'Verloopt na', + 'admin.invite.uses': 'gebruikt', + 'admin.invite.expiresAt': 'verloopt op', + 'admin.invite.createdBy': 'door', + 'admin.invite.active': 'Actief', + 'admin.invite.expired': 'Verlopen', + 'admin.invite.usedUp': 'Opgebruikt', + 'admin.invite.copied': 'Uitnodigingslink gekopieerd', + 'admin.invite.copyLink': 'Link kopiëren', + 'admin.invite.deleted': 'Uitnodigingslink verwijderd', + 'admin.invite.createError': 'Fout bij aanmaken van link', + 'admin.invite.deleteError': 'Fout bij verwijderen van link', 'admin.tabs.settings': 'Instellingen', 'admin.allowRegistration': 'Registratie toestaan', 'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 3d0a2b5..245bdc5 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -274,6 +274,24 @@ const ru: Record = { 'admin.toast.createError': 'Ошибка создания пользователя', 'admin.toast.fieldsRequired': 'Имя пользователя, эл. почта и пароль обязательны', 'admin.createUser': 'Создать пользователя', + 'admin.invite.title': 'Ссылки-приглашения', + 'admin.invite.subtitle': 'Создание одноразовых ссылок для регистрации', + 'admin.invite.create': 'Создать ссылку', + 'admin.invite.createAndCopy': 'Создать и скопировать', + 'admin.invite.empty': 'Ссылки-приглашения ещё не созданы', + 'admin.invite.maxUses': 'Макс. использований', + 'admin.invite.expiry': 'Действует', + 'admin.invite.uses': 'использовано', + 'admin.invite.expiresAt': 'истекает', + 'admin.invite.createdBy': 'от', + 'admin.invite.active': 'Активна', + 'admin.invite.expired': 'Истекла', + 'admin.invite.usedUp': 'Исчерпана', + 'admin.invite.copied': 'Ссылка-приглашение скопирована', + 'admin.invite.copyLink': 'Копировать ссылку', + 'admin.invite.deleted': 'Ссылка-приглашение удалена', + 'admin.invite.createError': 'Ошибка при создании ссылки', + 'admin.invite.deleteError': 'Ошибка при удалении ссылки', 'admin.tabs.settings': 'Настройки', 'admin.allowRegistration': 'Разрешить регистрацию', 'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index fd44b67..addd49e 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -274,6 +274,24 @@ const zh: Record = { 'admin.toast.createError': '创建用户失败', 'admin.toast.fieldsRequired': '用户名、邮箱和密码为必填项', 'admin.createUser': '创建用户', + 'admin.invite.title': '邀请链接', + 'admin.invite.subtitle': '创建一次性注册链接', + 'admin.invite.create': '创建链接', + 'admin.invite.createAndCopy': '创建并复制', + 'admin.invite.empty': '尚未创建邀请链接', + 'admin.invite.maxUses': '最大使用次数', + 'admin.invite.expiry': '有效期', + 'admin.invite.uses': '已使用', + 'admin.invite.expiresAt': '过期时间', + 'admin.invite.createdBy': '由', + 'admin.invite.active': '有效', + 'admin.invite.expired': '已过期', + 'admin.invite.usedUp': '已用完', + 'admin.invite.copied': '邀请链接已复制', + 'admin.invite.copyLink': '复制链接', + 'admin.invite.deleted': '邀请链接已删除', + 'admin.invite.createError': '创建链接失败', + 'admin.invite.deleteError': '删除链接失败', 'admin.tabs.settings': '设置', 'admin.allowRegistration': '允许注册', 'admin.allowRegistrationHint': '新用户可以自行注册', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 44a6ce9..1846773 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -12,7 +12,7 @@ import CategoryManager from '../components/Admin/CategoryManager' import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' -import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react' +import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' interface AdminUser { @@ -79,6 +79,11 @@ export default function AdminPage(): React.ReactElement { // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) + // Invite links + const [invites, setInvites] = useState([]) + const [showCreateInvite, setShowCreateInvite] = useState(false) + const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 }) + // File types const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') const [savingFileTypes, setSavingFileTypes] = useState(false) @@ -114,12 +119,14 @@ export default function AdminPage(): React.ReactElement { const loadData = async () => { setIsLoading(true) try { - const [usersData, statsData] = await Promise.all([ + const [usersData, statsData, invitesData] = await Promise.all([ adminApi.users(), adminApi.stats(), + adminApi.listInvites().catch(() => ({ invites: [] })), ]) setUsers(usersData.users) setStats(statsData) + setInvites(invitesData.invites || []) } catch (err: unknown) { toast.error(t('admin.toast.loadError')) } finally { @@ -240,6 +247,38 @@ export default function AdminPage(): React.ReactElement { } } + const handleCreateInvite = async () => { + try { + const data = await adminApi.createInvite({ + max_uses: inviteForm.max_uses, + expires_in_days: inviteForm.expires_in_days || undefined, + }) + setInvites(prev => [data.invite, ...prev]) + setShowCreateInvite(false) + setInviteForm({ max_uses: 1, expires_in_days: 7 }) + // Copy link to clipboard + const link = `${window.location.origin}/register?invite=${data.invite.token}` + navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied'))) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('admin.invite.createError'))) + } + } + + const handleDeleteInvite = async (id: number) => { + try { + await adminApi.deleteInvite(id) + setInvites(prev => prev.filter(i => i.id !== id)) + toast.success(t('admin.invite.deleted')) + } catch { + toast.error(t('admin.invite.deleteError')) + } + } + + const copyInviteLink = (token: string) => { + const link = `${window.location.origin}/register?invite=${token}` + navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied'))) + } + const handleEditUser = (user) => { setEditingUser(user) setEditForm({ username: user.username, email: user.email, role: user.role, password: '' }) @@ -501,6 +540,109 @@ export default function AdminPage(): React.ReactElement { )} + {/* Invite Links (inside users tab) */} + {activeTab === 'users' && ( +
+
+
+

{t('admin.invite.title')}

+

{t('admin.invite.subtitle')}

+
+ +
+ + {invites.length === 0 ? ( +
{t('admin.invite.empty')}
+ ) : ( +
+ {invites.map(inv => { + const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date() + const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses + const isActive = !isExpired && !isUsedUp + return ( +
+ +
+
+ {inv.token.slice(0, 12)}... + + {isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')} + +
+
+ {inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} + {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`} + {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`} +
+
+ {isActive && ( + + )} + +
+ ) + })} +
+ )} +
+ )} + + {/* Create Invite Modal */} + setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm"> +
+
+ +
+ {[1, 2, 3, 4, 5, 0].map(n => ( + + ))} +
+
+
+ +
+ {[ + { value: 1, label: '1d' }, + { value: 3, label: '3d' }, + { value: 7, label: '7d' }, + { value: 14, label: '14d' }, + { value: '', label: '∞' }, + ].map(opt => ( + + ))} +
+
+
+ + +
+
+
+ {activeTab === 'categories' && } {activeTab === 'addons' && } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 2b4cc4e..638c9e9 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -25,6 +25,8 @@ export default function LoginPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) + const [inviteToken, setInviteToken] = useState('') + const [inviteValid, setInviteValid] = useState(false) const { login, register, demoLogin } = useAuthStore() const { setLanguageLocal } = useSettingsStore() @@ -38,8 +40,23 @@ export default function LoginPage(): React.ReactElement { } }) - // Handle OIDC callback via short-lived auth code (secure exchange) + // Handle query params (invite token, OIDC callback) const params = new URLSearchParams(window.location.search) + + // Check for invite token in URL (/register?invite=xxx or /login?invite=xxx) + const invite = params.get('invite') + if (invite) { + setInviteToken(invite) + setMode('register') + authApi.validateInvite(invite).then(() => { + setInviteValid(true) + }).catch(() => { + setError('Invalid or expired invite link') + }) + window.history.replaceState({}, '', window.location.pathname) + } + + // Handle OIDC callback via short-lived auth code (secure exchange) const oidcCode = params.get('oidc_code') const oidcError = params.get('oidc_error') if (oidcCode) { @@ -93,7 +110,7 @@ export default function LoginPage(): React.ReactElement { if (mode === 'register') { if (!username.trim()) { setError('Username is required'); setIsLoading(false); return } if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return } - await register(username, email, password) + await register(username, email, password, inviteToken || undefined) } else { await login(email, password) } @@ -105,7 +122,7 @@ export default function LoginPage(): React.ReactElement { } } - const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users) && !appConfig?.oidc_only_mode + const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode // In OIDC-only mode, show a minimal page that redirects directly to the IdP const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index ee1f9b0..2b037bc 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -66,10 +66,10 @@ export const useAuthStore = create((set, get) => ({ } }, - register: async (username: string, email: string, password: string) => { + register: async (username: string, email: string, password: string, invite_token?: string) => { set({ isLoading: true, error: null }) try { - const data = await authApi.register({ username, email, password }) + const data = await authApi.register({ username, email, password, invite_token }) localStorage.setItem('auth_token', data.token) set({ user: data.user, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a56f08e..a30a96d 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -205,6 +205,17 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {} try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {} }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index b460e77..14512b8 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -221,6 +222,50 @@ router.post('/update', async (_req: Request, res: Response) => { } }); +// ── Invite Tokens ─────────────────────────────────────────────────────────── + +router.get('/invites', (_req: Request, res: Response) => { + const invites = db.prepare(` + SELECT i.*, u.username as created_by_name + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + ORDER BY i.created_at DESC + `).all(); + res.json({ invites }); +}); + +router.post('/invites', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { max_uses, expires_in_days } = req.body; + + const rawUses = parseInt(max_uses); + const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); + const token = crypto.randomBytes(16).toString('hex'); + const expiresAt = expires_in_days + ? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString() + : null; + + db.prepare( + 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' + ).run(token, uses, expiresAt, authReq.user.id); + + const invite = db.prepare(` + SELECT i.*, u.username as created_by_name + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + WHERE i.id = last_insert_rowid() + `).get(); + + res.status(201).json({ invite }); +}); + +router.delete('/invites/:id', (_req: Request, res: Response) => { + const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id); + if (!invite) return res.status(404).json({ error: 'Invite not found' }); + db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id); + res.json({ success: true }); +}); + router.get('/addons', (_req: Request, res: Response) => { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7516d3a..a986e0f 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -128,14 +128,33 @@ router.post('/demo-login', (_req: Request, res: Response) => { res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); }); +// Validate invite token (public, no auth needed, rate limited) +router.get('/invite/:token', authLimiter, (req: Request, res: Response) => { + const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(req.params.token) as any; + if (!invite) return res.status(404).json({ error: 'Invalid invite link' }); + if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' }); + if (invite.expires_at && new Date(invite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' }); + res.json({ valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at }); +}); + router.post('/register', authLimiter, (req: Request, res: Response) => { - const { username, email, password } = req.body; + const { username, email, password, invite_token } = req.body; const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; - if (userCount > 0 && isOidcOnlyMode()) { - return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + + // Check invite token first — valid token bypasses registration restrictions + let validInvite: any = null; + if (invite_token) { + validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token); + if (!validInvite) return res.status(400).json({ error: 'Invalid invite link' }); + if (validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' }); + if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' }); } - if (userCount > 0) { + + if (userCount > 0 && !validInvite) { + if (isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + } const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; if (setting?.value === 'false') { return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' }); @@ -177,6 +196,17 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { const user = { id: result.lastInsertRowid, username, email, role, avatar: null }; const token = generateToken(user); + // Atomically increment invite token usage (prevents race condition) + if (validInvite) { + const updated = db.prepare( + 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count' + ).get(validInvite.id); + if (!updated) { + // Race condition: token was used up between check and now — user was already created, so just log it + console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`); + } + } + res.status(201).json({ token, user: { ...user, avatar_url: null } }); } catch (err: unknown) { res.status(500).json({ error: 'Error creating user' });