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,
|
||||
|
||||
Reference in New Issue
Block a user