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:
Maurice
2026-03-29 12:49:15 +02:00
parent d909aac751
commit 99514ddce1
15 changed files with 388 additions and 13 deletions

View File

@@ -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 />}

View File

@@ -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