feat: add invite registration links with configurable usage limits
Admins can create one-time registration links (1–5× or unlimited uses) with optional expiry (1d–14d or never). Recipients can register even when public registration is disabled. Atomic usage counting prevents race conditions, all endpoints are rate-limited.
This commit is contained in:
@@ -107,7 +107,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
|
||||
@@ -39,7 +39,8 @@ apiClient.interceptors.response.use(
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (data: { username: string; email: string; password: string }) => 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<string, unknown>) => 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 = {
|
||||
|
||||
@@ -274,6 +274,24 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -274,6 +274,24 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -272,6 +272,24 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -274,6 +274,24 @@ const fr: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -274,6 +274,24 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -274,6 +274,24 @@ const ru: Record<string, string> = {
|
||||
'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': 'Новые пользователи могут регистрироваться самостоятельно',
|
||||
|
||||
@@ -274,6 +274,24 @@ const zh: Record<string, string> = {
|
||||
'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': '新用户可以自行注册',
|
||||
|
||||
@@ -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<boolean>(true)
|
||||
|
||||
// Invite links
|
||||
const [invites, setInvites] = useState<any[]>([])
|
||||
const [showCreateInvite, setShowCreateInvite] = useState<boolean>(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<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(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 {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite Links (inside users tab) */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mt-6">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.invite.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.invite.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateInvite(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('admin.invite.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-slate-400">{t('admin.invite.empty')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{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 (
|
||||
<div key={inv.id} className="px-5 py-3 flex items-center gap-4">
|
||||
<Link2 className="w-4 h-4 flex-shrink-0" style={{ color: isActive ? 'var(--text-primary)' : '#d1d5db' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-slate-600 truncate">{inv.token.slice(0, 12)}...</code>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
isActive ? 'bg-green-50 text-green-700' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{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}`}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<button onClick={() => copyInviteLink(inv.token)} title={t('admin.invite.copyLink')}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleDeleteInvite(inv.id)} title={t('common.delete')}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Invite Modal */}
|
||||
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.maxUses')}</label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5, 0].map(n => (
|
||||
<button key={n} type="button" onClick={() => setInviteForm(f => ({ ...f, max_uses: n }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
|
||||
inviteForm.max_uses === n ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
||||
}`}>
|
||||
{n === 0 ? '∞' : `${n}×`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.expiry')}</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 1, label: '1d' },
|
||||
{ value: 3, label: '3d' },
|
||||
{ value: 7, label: '7d' },
|
||||
{ value: 14, label: '14d' },
|
||||
{ value: '', label: '∞' },
|
||||
].map(opt => (
|
||||
<button key={String(opt.value)} type="button" onClick={() => setInviteForm(f => ({ ...f, expires_in_days: opt.value as number | '' }))}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
|
||||
inviteForm.expires_in_days === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
|
||||
<button onClick={() => setShowCreateInvite(false)} className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateInvite} className="px-4 py-2 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700">{t('admin.invite.createAndCopy')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{activeTab === 'categories' && <CategoryManager />}
|
||||
|
||||
{activeTab === 'addons' && <AddonManager />}
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(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
|
||||
|
||||
@@ -66,10 +66,10 @@ export const useAuthStore = create<AuthState>((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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || '{}') })) });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user