feat(admin): add auth settings page for OAuth, invites, email, and registration mode (#287)

Add /admin/settings/auth page with four configuration sections:
- Registration mode (open/invite-only/closed) radio group
- Invite codes management with generate/delete/copy actions
- OAuth providers CRUD with built-in and custom OIDC support
- Email configuration (SMTP/HTTP) with test email functionality

Includes RPC clients, API functions, i18n (en/zh), and locale tests.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-13 20:13:50 -04:00
committed by GitHub
parent bd30514d5b
commit 89734539fe
12 changed files with 1450 additions and 2 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'
import { ArrowLeft, Database, Settings, Users } from 'lucide-react'
import { ArrowLeft, Database, KeyRound, Settings, Users } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
@@ -19,6 +19,7 @@ const adminNavItems = [
{ titleKey: 'admin.nav.storages', url: '/admin/storages', icon: Database },
{ titleKey: 'admin.nav.users', url: '/admin/users', icon: Users },
{ titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings },
{ titleKey: 'admin.nav.auth', url: '/admin/settings/auth', icon: KeyRound },
]
export function AdminSidebar() {
@@ -0,0 +1,239 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { type EmailConfigData, getEmailConfig, saveEmailConfig, testEmail } from '@/lib/api'
const emailConfigQueryKey = ['admin', 'email-config'] as const
type ProviderType = 'smtp' | 'http'
interface FormState {
provider: ProviderType
from: string
smtpHost: string
smtpPort: number
smtpUser: string
smtpPass: string
smtpSecure: boolean
httpUrl: string
httpApiKey: string
}
const emptyForm: FormState = {
provider: 'smtp',
from: '',
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
smtpSecure: true,
httpUrl: '',
httpApiKey: '',
}
function formToPayload(form: FormState): EmailConfigData {
if (form.provider === 'smtp') {
return {
provider: 'smtp',
from: form.from,
smtp: {
host: form.smtpHost,
port: form.smtpPort,
user: form.smtpUser,
pass: form.smtpPass,
secure: form.smtpSecure,
},
}
}
return {
provider: 'http',
from: form.from,
http: { url: form.httpUrl, apiKey: form.httpApiKey },
}
}
export function EmailConfigSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [form, setForm] = useState<FormState>(emptyForm)
const [testDialogOpen, setTestDialogOpen] = useState(false)
const [testEmailAddr, setTestEmailAddr] = useState('')
const { data, isLoading } = useQuery({
queryKey: emailConfigQueryKey,
queryFn: getEmailConfig,
})
useEffect(() => {
if (!data || data.provider === null) return
const config = data as EmailConfigData
if (config.provider === 'smtp') {
setForm({
provider: 'smtp',
from: config.from,
smtpHost: config.smtp.host,
smtpPort: config.smtp.port,
smtpUser: config.smtp.user,
smtpPass: config.smtp.pass,
smtpSecure: config.smtp.secure,
httpUrl: '',
httpApiKey: '',
})
} else {
setForm({
provider: 'http',
from: config.from,
smtpHost: '',
smtpPort: 587,
smtpUser: '',
smtpPass: '',
smtpSecure: true,
httpUrl: config.http.url,
httpApiKey: config.http.apiKey,
})
}
}, [data])
const saveMutation = useMutation({
mutationFn: () => saveEmailConfig(formToPayload(form)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: emailConfigQueryKey })
toast.success(t('admin.auth.emailSaved'))
},
onError: (err) => toast.error(err.message),
})
const testMutation = useMutation({
mutationFn: testEmail,
onSuccess: () => {
toast.success(t('admin.auth.testEmailSent'))
setTestDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const update = (patch: Partial<FormState>) => setForm((prev) => ({ ...prev, ...patch }))
if (isLoading) return <p className="text-sm text-muted-foreground">{t('common.loading')}</p>
return (
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.emailSection')}</h3>
<div className="max-w-lg space-y-4">
<div className="space-y-1.5">
<Label>{t('admin.auth.emailProvider')}</Label>
<Select value={form.provider} onValueChange={(v) => update({ provider: v as ProviderType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">{t('admin.auth.emailSmtp')}</SelectItem>
<SelectItem value="http">{t('admin.auth.emailHttp')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.emailFrom')}</Label>
<Input type="email" value={form.from} onChange={(e) => update({ from: e.target.value })} />
</div>
{form.provider === 'smtp' ? (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>{t('admin.auth.smtpHost')}</Label>
<Input value={form.smtpHost} onChange={(e) => update({ smtpHost: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.smtpPort')}</Label>
<Input
type="number"
value={form.smtpPort}
onChange={(e) => update({ smtpPort: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.smtpUser')}</Label>
<Input value={form.smtpUser} onChange={(e) => update({ smtpUser: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.smtpPass')}</Label>
<Input type="password" value={form.smtpPass} onChange={(e) => update({ smtpPass: e.target.value })} />
</div>
<div className="flex items-center gap-2">
<Checkbox
id="smtpSecure"
checked={form.smtpSecure}
onCheckedChange={(v) => update({ smtpSecure: !!v })}
/>
<Label htmlFor="smtpSecure">{t('admin.auth.smtpSecure')}</Label>
</div>
</>
) : (
<>
<div className="space-y-1.5">
<Label>{t('admin.auth.httpUrl')}</Label>
<Input value={form.httpUrl} onChange={(e) => update({ httpUrl: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.httpApiKey')}</Label>
<Input type="password" value={form.httpApiKey} onChange={(e) => update({ httpApiKey: e.target.value })} />
</div>
</>
)}
<div className="flex gap-2">
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t('common.loading') : t('common.save')}
</Button>
<Button variant="outline" onClick={() => setTestDialogOpen(true)}>
{t('admin.auth.testEmail')}
</Button>
</div>
</div>
<Dialog open={testDialogOpen} onOpenChange={setTestDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.auth.testEmail')}</DialogTitle>
<DialogDescription>{t('admin.auth.testEmailTo')}</DialogDescription>
</DialogHeader>
<Input
type="email"
value={testEmailAddr}
onChange={(e) => setTestEmailAddr(e.target.value)}
placeholder="test@example.com"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setTestDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={() => testMutation.mutate(testEmailAddr)}
disabled={testMutation.isPending || !testEmailAddr}
>
{testMutation.isPending ? t('common.loading') : t('admin.auth.testEmail')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,184 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { deleteInviteCode, generateInviteCodes, type InviteCode, listInviteCodes } from '@/lib/api'
const inviteCodesQueryKey = ['admin', 'invite-codes'] as const
function codeStatus(code: InviteCode): 'used' | 'expired' | 'available' {
if (code.usedBy) return 'used'
if (code.expiresAt && new Date(code.expiresAt) < new Date()) return 'expired'
return 'available'
}
function StatusBadge({ status }: { status: 'used' | 'expired' | 'available' }) {
const { t } = useTranslation()
const colors = {
available: 'bg-green-100 text-green-800',
used: 'bg-gray-100 text-gray-800',
expired: 'bg-red-100 text-red-800',
}
const labels = {
available: t('admin.auth.statusAvailable'),
used: t('admin.auth.statusUsed'),
expired: t('admin.auth.statusExpired'),
}
return (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${colors[status]}`}>{labels[status]}</span>
)
}
export function InviteCodesSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [count, setCount] = useState(10)
const [expiresInDays, setExpiresInDays] = useState('')
const { data, isLoading } = useQuery({
queryKey: inviteCodesQueryKey,
queryFn: () => listInviteCodes(1, 100),
})
const generateMutation = useMutation({
mutationFn: () => generateInviteCodes(count, expiresInDays ? Number(expiresInDays) : undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: inviteCodesQueryKey })
toast.success(t('admin.auth.codesGenerated'))
setDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: deleteInviteCode,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: inviteCodesQueryKey })
toast.success(t('admin.auth.codeDeleted'))
},
onError: (err) => toast.error(err.message),
})
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code).then(
() => toast.success(t('admin.auth.codeCopied')),
() => toast.error(t('common.error')),
)
}
const codes = data?.items ?? []
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.inviteCodesSection')}</h3>
<Button size="sm" onClick={() => setDialogOpen(true)}>
{t('admin.auth.generateCodes')}
</Button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : codes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.auth.noInviteCodes')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.auth.colCode')}</TableHead>
<TableHead>{t('admin.auth.colStatus')}</TableHead>
<TableHead>{t('admin.auth.colUsedBy')}</TableHead>
<TableHead>{t('admin.auth.colExpiresAt')}</TableHead>
<TableHead>{t('admin.auth.colCreatedAt')}</TableHead>
<TableHead>{t('admin.auth.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{codes.map((code) => {
const status = codeStatus(code)
return (
<TableRow key={code.id}>
<TableCell>
<button
type="button"
onClick={() => copyToClipboard(code.code)}
className="font-mono text-sm hover:underline cursor-pointer"
>
{code.code}
</button>
</TableCell>
<TableCell>
<StatusBadge status={status} />
</TableCell>
<TableCell className="text-sm">{code.usedBy ?? '—'}</TableCell>
<TableCell className="text-sm">
{code.expiresAt ? new Date(code.expiresAt).toLocaleDateString() : '—'}
</TableCell>
<TableCell className="text-sm">{new Date(code.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
{status === 'available' && (
<Button
variant="ghost"
size="sm"
onClick={() => deleteMutation.mutate(code.id)}
disabled={deleteMutation.isPending}
>
{t('common.delete')}
</Button>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.auth.generateTitle')}</DialogTitle>
<DialogDescription>{t('admin.auth.expiresInDaysHint')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>{t('admin.auth.codeCount')}</Label>
<Input type="number" min={1} max={100} value={count} onChange={(e) => setCount(Number(e.target.value))} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.expiresInDays')}</Label>
<Input
type="number"
min={1}
placeholder={t('admin.auth.expiresInDaysHint')}
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={() => generateMutation.mutate()} disabled={generateMutation.isPending}>
{generateMutation.isPending ? t('common.loading') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,305 @@
import { BUILTIN_PROVIDER_IDS, type OAuthProviderConfig, OAuthProviderMeta } from '@shared/oauth-providers'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { deleteAuthProvider, listAdminAuthProviders, upsertAuthProvider } from '@/lib/api'
const providersQueryKey = ['admin', 'auth-providers'] as const
type ProviderType = 'builtin' | 'oidc'
interface FormState {
type: ProviderType
providerId: string
clientId: string
clientSecret: string
enabled: boolean
discoveryUrl: string
scopes: string
}
const emptyForm: FormState = {
type: 'builtin',
providerId: '',
clientId: '',
clientSecret: '',
enabled: true,
discoveryUrl: '',
scopes: '',
}
function providerName(providerId: string): string {
return OAuthProviderMeta[providerId]?.name ?? providerId
}
export function OAuthProvidersSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const { data, isLoading } = useQuery({
queryKey: providersQueryKey,
queryFn: listAdminAuthProviders,
})
const upsertMutation = useMutation({
mutationFn: () => {
return upsertAuthProvider(form.providerId, {
type: form.type,
clientId: form.clientId,
clientSecret: form.clientSecret,
enabled: form.enabled,
...(form.type === 'oidc'
? {
discoveryUrl: form.discoveryUrl,
scopes: form.scopes
.split(',')
.map((s) => s.trim())
.filter(Boolean),
}
: {}),
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providersQueryKey })
toast.success(t('admin.auth.providerSaved'))
setDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: deleteAuthProvider,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: providersQueryKey })
toast.success(t('admin.auth.providerDeleted'))
setDeleteDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const openAdd = () => {
setForm(emptyForm)
setEditingId(null)
setDialogOpen(true)
}
const openEdit = (p: OAuthProviderConfig) => {
setForm({
type: p.type,
providerId: p.providerId,
clientId: p.clientId,
clientSecret: p.clientSecret,
enabled: p.enabled,
discoveryUrl: p.discoveryUrl ?? '',
scopes: p.scopes?.join(', ') ?? '',
})
setEditingId(p.providerId)
setDialogOpen(true)
}
const openDelete = (providerId: string) => {
setDeletingId(providerId)
setDeleteDialogOpen(true)
}
const providers = data?.items ?? []
const update = (patch: Partial<FormState>) => setForm((prev) => ({ ...prev, ...patch }))
return (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.oauthSection')}</h3>
<Button size="sm" onClick={openAdd}>
{t('admin.auth.addProvider')}
</Button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : providers.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('admin.auth.noProviders')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.auth.provider')}</TableHead>
<TableHead>{t('admin.auth.providerType')}</TableHead>
<TableHead>{t('admin.auth.clientId')}</TableHead>
<TableHead>{t('admin.auth.enabled')}</TableHead>
<TableHead>{t('admin.auth.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providers.map((p) => (
<TableRow key={p.providerId}>
<TableCell className="font-medium">{providerName(p.providerId)}</TableCell>
<TableCell className="text-sm">{p.type}</TableCell>
<TableCell className="text-sm font-mono">{p.clientId}</TableCell>
<TableCell>
<span
className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${p.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
>
{p.enabled ? t('admin.auth.statusEnabled') : t('admin.auth.statusDisabled')}
</span>
</TableCell>
<TableCell className="space-x-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
{t('common.edit')}
</Button>
<Button variant="ghost" size="sm" onClick={() => openDelete(p.providerId)}>
{t('common.delete')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingId ? t('admin.auth.editProviderTitle') : t('admin.auth.addProviderTitle')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>{t('admin.auth.providerType')}</Label>
<Select
value={form.type}
onValueChange={(v) => update({ type: v as ProviderType, providerId: '' })}
disabled={!!editingId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="builtin">{t('admin.auth.providerBuiltin')}</SelectItem>
<SelectItem value="oidc">{t('admin.auth.providerOidc')}</SelectItem>
</SelectContent>
</Select>
</div>
{form.type === 'builtin' ? (
<div className="space-y-1.5">
<Label>{t('admin.auth.provider')}</Label>
<Select value={form.providerId} onValueChange={(v) => update({ providerId: v })} disabled={!!editingId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUILTIN_PROVIDER_IDS.map((id) => (
<SelectItem key={id} value={id}>
{OAuthProviderMeta[id]?.name ?? id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1.5">
<Label>{t('admin.auth.providerId')}</Label>
<Input
value={form.providerId}
onChange={(e) => update({ providerId: e.target.value })}
placeholder={t('admin.auth.providerIdHint')}
disabled={!!editingId}
/>
</div>
)}
<div className="space-y-1.5">
<Label>{t('admin.auth.clientId')}</Label>
<Input value={form.clientId} onChange={(e) => update({ clientId: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.clientSecret')}</Label>
<Input
type="password"
value={form.clientSecret}
onChange={(e) => update({ clientSecret: e.target.value })}
/>
</div>
{form.type === 'oidc' && (
<>
<div className="space-y-1.5">
<Label>{t('admin.auth.discoveryUrl')}</Label>
<Input value={form.discoveryUrl} onChange={(e) => update({ discoveryUrl: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>{t('admin.auth.scopes')}</Label>
<Input
value={form.scopes}
onChange={(e) => update({ scopes: e.target.value })}
placeholder={t('admin.auth.scopesHint')}
/>
</div>
</>
)}
<div className="flex items-center gap-2">
<Checkbox id="providerEnabled" checked={form.enabled} onCheckedChange={(v) => update({ enabled: !!v })} />
<Label htmlFor="providerEnabled">{t('admin.auth.enabled')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={() => upsertMutation.mutate()}
disabled={upsertMutation.isPending || !form.providerId || !form.clientId || !form.clientSecret}
>
{upsertMutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.auth.deleteProviderTitle')}</DialogTitle>
<DialogDescription>
{t('admin.auth.deleteProviderConfirm', { name: deletingId ? providerName(deletingId) : '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate(deletingId!)}
disabled={deleteMutation.isPending || !deletingId}
>
{deleteMutation.isPending ? t('common.loading') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,61 @@
import { SignupMode } from '@shared/constants'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Label } from '@/components/ui/label'
import { siteOptionsQueryKey, useSiteOptions } from '@/hooks/use-site-options'
import { setSystemOption } from '@/lib/api'
const modes = [
{ value: SignupMode.OPEN, labelKey: 'admin.auth.registrationOpen', descKey: 'admin.auth.registrationOpenDesc' },
{
value: SignupMode.INVITE_ONLY,
labelKey: 'admin.auth.registrationInviteOnly',
descKey: 'admin.auth.registrationInviteOnlyDesc',
},
{
value: SignupMode.CLOSED,
labelKey: 'admin.auth.registrationClosed',
descKey: 'admin.auth.registrationClosedDesc',
},
] as const
export function RegistrationModeSection() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { authSignupMode } = useSiteOptions()
const mutation = useMutation({
mutationFn: (mode: string) => setSystemOption('auth_signup_mode', mode, true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: siteOptionsQueryKey })
toast.success(t('admin.auth.registrationSaved'))
},
onError: (err) => toast.error(err.message),
})
return (
<div className="space-y-4 rounded-md border p-4">
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.registrationSection')}</h3>
<div className="space-y-3">
{modes.map((mode) => (
<Label key={mode.value} className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="signupMode"
value={mode.value}
checked={authSignupMode === mode.value}
onChange={() => mutation.mutate(mode.value)}
disabled={mutation.isPending}
className="mt-1"
/>
<div>
<div className="font-medium">{t(mode.labelKey)}</div>
<div className="text-xs text-muted-foreground">{t(mode.descKey)}</div>
</div>
</Label>
))}
</div>
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import zh from './locales/zh.json'
const enLocale = en as Record<string, string>
const zhLocale = zh as Record<string, string>
const ADMIN_NAV_AUTH_KEYS = ['admin.nav.auth']
const ADMIN_AUTH_KEYS = [
'admin.auth.title',
'admin.auth.registrationSection',
'admin.auth.registrationOpen',
'admin.auth.registrationOpenDesc',
'admin.auth.registrationInviteOnly',
'admin.auth.registrationInviteOnlyDesc',
'admin.auth.registrationClosed',
'admin.auth.registrationClosedDesc',
'admin.auth.registrationSaved',
'admin.auth.inviteCodesSection',
'admin.auth.generateCodes',
'admin.auth.generateTitle',
'admin.auth.codeCount',
'admin.auth.expiresInDays',
'admin.auth.expiresInDaysHint',
'admin.auth.colCode',
'admin.auth.colStatus',
'admin.auth.colUsedBy',
'admin.auth.colExpiresAt',
'admin.auth.colCreatedAt',
'admin.auth.colActions',
'admin.auth.statusAvailable',
'admin.auth.statusUsed',
'admin.auth.statusExpired',
'admin.auth.statusEnabled',
'admin.auth.statusDisabled',
'admin.auth.noInviteCodes',
'admin.auth.codesGenerated',
'admin.auth.codeDeleted',
'admin.auth.codeCopied',
'admin.auth.oauthSection',
'admin.auth.addProvider',
'admin.auth.addProviderTitle',
'admin.auth.editProviderTitle',
'admin.auth.providerType',
'admin.auth.providerBuiltin',
'admin.auth.providerOidc',
'admin.auth.provider',
'admin.auth.clientId',
'admin.auth.clientSecret',
'admin.auth.enabled',
'admin.auth.discoveryUrl',
'admin.auth.scopes',
'admin.auth.scopesHint',
'admin.auth.providerId',
'admin.auth.providerIdHint',
'admin.auth.providerSaved',
'admin.auth.providerDeleted',
'admin.auth.noProviders',
'admin.auth.deleteProviderTitle',
'admin.auth.deleteProviderConfirm',
'admin.auth.emailSection',
'admin.auth.emailProvider',
'admin.auth.emailSmtp',
'admin.auth.emailHttp',
'admin.auth.emailFrom',
'admin.auth.smtpHost',
'admin.auth.smtpPort',
'admin.auth.smtpUser',
'admin.auth.smtpPass',
'admin.auth.smtpSecure',
'admin.auth.httpUrl',
'admin.auth.httpApiKey',
'admin.auth.testEmail',
'admin.auth.testEmailTo',
'admin.auth.testEmailSent',
'admin.auth.testEmailFailed',
'admin.auth.emailSaved',
'admin.auth.emailNotConfigured',
]
const ALL_KEYS = [...ADMIN_NAV_AUTH_KEYS, ...ADMIN_AUTH_KEYS]
describe('admin.auth locale keys — presence', () => {
for (const key of ALL_KEYS) {
it(`en.json contains key "${key}"`, () => {
expect(Object.hasOwn(enLocale, key)).toBe(true)
})
it(`zh.json contains key "${key}"`, () => {
expect(Object.hasOwn(zhLocale, key)).toBe(true)
})
}
})
describe('admin.auth locale keys — non-empty values', () => {
for (const key of ALL_KEYS) {
it(`en.json value for "${key}" is not empty`, () => {
expect(enLocale[key]).toBeTruthy()
})
it(`zh.json value for "${key}" is not empty`, () => {
expect(zhLocale[key]).toBeTruthy()
})
}
})
describe('admin.auth locale keys — English values contract', () => {
it('admin.nav.auth is "Auth"', () => {
expect(enLocale['admin.nav.auth']).toBe('Auth')
})
it('admin.auth.title is "Authentication"', () => {
expect(enLocale['admin.auth.title']).toBe('Authentication')
})
it('admin.auth.registrationSection is "Registration Mode"', () => {
expect(enLocale['admin.auth.registrationSection']).toBe('Registration Mode')
})
it('admin.auth.registrationOpen is "Open"', () => {
expect(enLocale['admin.auth.registrationOpen']).toBe('Open')
})
it('admin.auth.registrationOpenDesc is "Anyone can sign up"', () => {
expect(enLocale['admin.auth.registrationOpenDesc']).toBe('Anyone can sign up')
})
it('admin.auth.registrationInviteOnly is "Invite Only"', () => {
expect(enLocale['admin.auth.registrationInviteOnly']).toBe('Invite Only')
})
it('admin.auth.registrationInviteOnlyDesc is "Users need an invite code to sign up"', () => {
expect(enLocale['admin.auth.registrationInviteOnlyDesc']).toBe('Users need an invite code to sign up')
})
it('admin.auth.registrationClosed is "Closed"', () => {
expect(enLocale['admin.auth.registrationClosed']).toBe('Closed')
})
it('admin.auth.registrationClosedDesc is "No new sign-ups allowed"', () => {
expect(enLocale['admin.auth.registrationClosedDesc']).toBe('No new sign-ups allowed')
})
it('admin.auth.registrationSaved is "Registration mode saved"', () => {
expect(enLocale['admin.auth.registrationSaved']).toBe('Registration mode saved')
})
it('admin.auth.inviteCodesSection is "Invite Codes"', () => {
expect(enLocale['admin.auth.inviteCodesSection']).toBe('Invite Codes')
})
it('admin.auth.generateCodes is "Generate Codes"', () => {
expect(enLocale['admin.auth.generateCodes']).toBe('Generate Codes')
})
it('admin.auth.statusAvailable is "Available"', () => {
expect(enLocale['admin.auth.statusAvailable']).toBe('Available')
})
it('admin.auth.statusUsed is "Used"', () => {
expect(enLocale['admin.auth.statusUsed']).toBe('Used')
})
it('admin.auth.statusExpired is "Expired"', () => {
expect(enLocale['admin.auth.statusExpired']).toBe('Expired')
})
it('admin.auth.oauthSection is "OAuth Providers"', () => {
expect(enLocale['admin.auth.oauthSection']).toBe('OAuth Providers')
})
it('admin.auth.addProvider is "Add Provider"', () => {
expect(enLocale['admin.auth.addProvider']).toBe('Add Provider')
})
it('admin.auth.providerBuiltin is "Built-in Provider"', () => {
expect(enLocale['admin.auth.providerBuiltin']).toBe('Built-in Provider')
})
it('admin.auth.providerOidc is "Custom OIDC"', () => {
expect(enLocale['admin.auth.providerOidc']).toBe('Custom OIDC')
})
it('admin.auth.emailSection is "Email Configuration"', () => {
expect(enLocale['admin.auth.emailSection']).toBe('Email Configuration')
})
it('admin.auth.emailSmtp is "SMTP"', () => {
expect(enLocale['admin.auth.emailSmtp']).toBe('SMTP')
})
it('admin.auth.emailHttp is "HTTP API"', () => {
expect(enLocale['admin.auth.emailHttp']).toBe('HTTP API')
})
it('admin.auth.testEmail is "Send Test Email"', () => {
expect(enLocale['admin.auth.testEmail']).toBe('Send Test Email')
})
it('admin.auth.testEmailSent is "Test email sent"', () => {
expect(enLocale['admin.auth.testEmailSent']).toBe('Test email sent')
})
it('admin.auth.testEmailFailed is "Test email failed"', () => {
expect(enLocale['admin.auth.testEmailFailed']).toBe('Test email failed')
})
it('admin.auth.emailSaved is "Email configuration saved"', () => {
expect(enLocale['admin.auth.emailSaved']).toBe('Email configuration saved')
})
it('admin.auth.emailNotConfigured is "Email not configured yet"', () => {
expect(enLocale['admin.auth.emailNotConfigured']).toBe('Email not configured yet')
})
it('admin.auth.providerSaved is "Provider saved"', () => {
expect(enLocale['admin.auth.providerSaved']).toBe('Provider saved')
})
it('admin.auth.providerDeleted is "Provider deleted"', () => {
expect(enLocale['admin.auth.providerDeleted']).toBe('Provider deleted')
})
it('admin.auth.noProviders is "No OAuth providers configured"', () => {
expect(enLocale['admin.auth.noProviders']).toBe('No OAuth providers configured')
})
it('admin.auth.codesGenerated is "Invite codes generated"', () => {
expect(enLocale['admin.auth.codesGenerated']).toBe('Invite codes generated')
})
it('admin.auth.codeDeleted is "Invite code deleted"', () => {
expect(enLocale['admin.auth.codeDeleted']).toBe('Invite code deleted')
})
it('admin.auth.codeCopied is "Code copied to clipboard"', () => {
expect(enLocale['admin.auth.codeCopied']).toBe('Code copied to clipboard')
})
})
describe('admin.auth locale keys — i18n runtime translation', () => {
it('translates admin.nav.auth to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.nav.auth')).toBe('Auth')
})
it('translates admin.nav.auth to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.nav.auth')).toBe('认证')
})
it('translates admin.auth.title to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.title')).toBe('Authentication')
})
it('translates admin.auth.title to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.title')).toBe('身份认证')
})
it('translates admin.auth.registrationSection to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.registrationSection')).toBe('Registration Mode')
})
it('translates admin.auth.registrationSection to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.registrationSection')).toBe('注册模式')
})
it('translates admin.auth.registrationOpen to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.registrationOpen')).toBe('Open')
})
it('translates admin.auth.registrationOpen to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.registrationOpen')).toBe('开放')
})
it('translates admin.auth.registrationInviteOnly to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.registrationInviteOnly')).toBe('Invite Only')
})
it('translates admin.auth.registrationInviteOnly to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.registrationInviteOnly')).toBe('邀请制')
})
it('translates admin.auth.registrationClosed to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.registrationClosed')).toBe('Closed')
})
it('translates admin.auth.registrationClosed to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.registrationClosed')).toBe('关闭')
})
it('translates admin.auth.oauthSection to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.oauthSection')).toBe('OAuth Providers')
})
it('translates admin.auth.oauthSection to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.oauthSection')).toBe('OAuth 提供商')
})
it('translates admin.auth.emailSection to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.emailSection')).toBe('Email Configuration')
})
it('translates admin.auth.emailSection to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.emailSection')).toBe('邮件配置')
})
it('translates admin.auth.inviteCodesSection to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.inviteCodesSection')).toBe('Invite Codes')
})
it('translates admin.auth.inviteCodesSection to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.inviteCodesSection')).toBe('邀请码')
})
it('translates admin.auth.noInviteCodes to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.noInviteCodes')).toBe('No invite codes')
})
it('translates admin.auth.noInviteCodes to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.noInviteCodes')).toBe('暂无邀请码')
})
it('translates admin.auth.noProviders to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.noProviders')).toBe('No OAuth providers configured')
})
it('translates admin.auth.noProviders to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.noProviders')).toBe('暂未配置 OAuth 提供商')
})
it('translates admin.auth.emailSaved to English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.auth.emailSaved')).toBe('Email configuration saved')
})
it('translates admin.auth.emailSaved to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.auth.emailSaved')).toBe('邮件配置已保存')
})
})
+70
View File
@@ -127,6 +127,76 @@
"admin.settings.defaultOrgQuota": "Default Quota (GB)",
"admin.settings.defaultOrgQuotaHint": "Storage quota for new users. 0 means unlimited.",
"admin.settings.saved": "Settings saved",
"admin.nav.auth": "Auth",
"admin.auth.title": "Authentication",
"admin.auth.registrationSection": "Registration Mode",
"admin.auth.registrationOpen": "Open",
"admin.auth.registrationOpenDesc": "Anyone can sign up",
"admin.auth.registrationInviteOnly": "Invite Only",
"admin.auth.registrationInviteOnlyDesc": "Users need an invite code to sign up",
"admin.auth.registrationClosed": "Closed",
"admin.auth.registrationClosedDesc": "No new sign-ups allowed",
"admin.auth.registrationSaved": "Registration mode saved",
"admin.auth.inviteCodesSection": "Invite Codes",
"admin.auth.generateCodes": "Generate Codes",
"admin.auth.generateTitle": "Generate Invite Codes",
"admin.auth.codeCount": "Number of codes",
"admin.auth.expiresInDays": "Expires in (days)",
"admin.auth.expiresInDaysHint": "Leave empty for no expiration",
"admin.auth.colCode": "Code",
"admin.auth.colStatus": "Status",
"admin.auth.colUsedBy": "Used By",
"admin.auth.colExpiresAt": "Expires At",
"admin.auth.colCreatedAt": "Created At",
"admin.auth.colActions": "Actions",
"admin.auth.statusAvailable": "Available",
"admin.auth.statusUsed": "Used",
"admin.auth.statusExpired": "Expired",
"admin.auth.noInviteCodes": "No invite codes",
"admin.auth.codesGenerated": "Invite codes generated",
"admin.auth.codeDeleted": "Invite code deleted",
"admin.auth.codeCopied": "Code copied to clipboard",
"admin.auth.oauthSection": "OAuth Providers",
"admin.auth.addProvider": "Add Provider",
"admin.auth.addProviderTitle": "Add OAuth Provider",
"admin.auth.editProviderTitle": "Edit OAuth Provider",
"admin.auth.providerType": "Provider Type",
"admin.auth.providerBuiltin": "Built-in Provider",
"admin.auth.providerOidc": "Custom OIDC",
"admin.auth.provider": "Provider",
"admin.auth.clientId": "Client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.enabled": "Enabled",
"admin.auth.discoveryUrl": "Discovery URL",
"admin.auth.scopes": "Scopes",
"admin.auth.scopesHint": "Comma-separated list of scopes",
"admin.auth.providerId": "Provider ID",
"admin.auth.providerIdHint": "Lowercase letters, numbers, and hyphens only",
"admin.auth.providerSaved": "Provider saved",
"admin.auth.providerDeleted": "Provider deleted",
"admin.auth.statusEnabled": "Enabled",
"admin.auth.statusDisabled": "Disabled",
"admin.auth.noProviders": "No OAuth providers configured",
"admin.auth.deleteProviderTitle": "Delete Provider",
"admin.auth.deleteProviderConfirm": "Delete OAuth provider '{{name}}'? Users will no longer be able to sign in with this provider.",
"admin.auth.emailSection": "Email Configuration",
"admin.auth.emailProvider": "Provider Type",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "From Address",
"admin.auth.smtpHost": "Host",
"admin.auth.smtpPort": "Port",
"admin.auth.smtpUser": "Username",
"admin.auth.smtpPass": "Password",
"admin.auth.smtpSecure": "Secure (TLS)",
"admin.auth.httpUrl": "API URL",
"admin.auth.httpApiKey": "API Key",
"admin.auth.testEmail": "Send Test Email",
"admin.auth.testEmailTo": "Recipient Email",
"admin.auth.testEmailSent": "Test email sent",
"admin.auth.testEmailFailed": "Test email failed",
"admin.auth.emailSaved": "Email configuration saved",
"admin.auth.emailNotConfigured": "Email not configured yet",
"admin.storages.title": "Storages",
"admin.storages.placeholder": "Configure your S3-compatible storage backends here.",
"admin.storages.add": "Add Storage",
+70
View File
@@ -127,6 +127,76 @@
"admin.settings.defaultOrgQuota": "默认配额 (GB)",
"admin.settings.defaultOrgQuotaHint": "新用户的存储配额,0 表示不限制。",
"admin.settings.saved": "设置已保存",
"admin.nav.auth": "认证",
"admin.auth.title": "身份认证",
"admin.auth.registrationSection": "注册模式",
"admin.auth.registrationOpen": "开放",
"admin.auth.registrationOpenDesc": "任何人都可以注册",
"admin.auth.registrationInviteOnly": "邀请制",
"admin.auth.registrationInviteOnlyDesc": "用户需要邀请码才能注册",
"admin.auth.registrationClosed": "关闭",
"admin.auth.registrationClosedDesc": "不允许新用户注册",
"admin.auth.registrationSaved": "注册模式已保存",
"admin.auth.inviteCodesSection": "邀请码",
"admin.auth.generateCodes": "生成邀请码",
"admin.auth.generateTitle": "生成邀请码",
"admin.auth.codeCount": "生成数量",
"admin.auth.expiresInDays": "过期天数",
"admin.auth.expiresInDaysHint": "留空表示永不过期",
"admin.auth.colCode": "邀请码",
"admin.auth.colStatus": "状态",
"admin.auth.colUsedBy": "使用者",
"admin.auth.colExpiresAt": "过期时间",
"admin.auth.colCreatedAt": "创建时间",
"admin.auth.colActions": "操作",
"admin.auth.statusAvailable": "可用",
"admin.auth.statusUsed": "已使用",
"admin.auth.statusExpired": "已过期",
"admin.auth.noInviteCodes": "暂无邀请码",
"admin.auth.codesGenerated": "邀请码已生成",
"admin.auth.codeDeleted": "邀请码已删除",
"admin.auth.codeCopied": "邀请码已复制",
"admin.auth.oauthSection": "OAuth 提供商",
"admin.auth.addProvider": "添加提供商",
"admin.auth.addProviderTitle": "添加 OAuth 提供商",
"admin.auth.editProviderTitle": "编辑 OAuth 提供商",
"admin.auth.providerType": "提供商类型",
"admin.auth.providerBuiltin": "内置提供商",
"admin.auth.providerOidc": "自定义 OIDC",
"admin.auth.provider": "提供商",
"admin.auth.clientId": "Client ID",
"admin.auth.clientSecret": "Client Secret",
"admin.auth.enabled": "启用",
"admin.auth.discoveryUrl": "Discovery URL",
"admin.auth.scopes": "Scopes",
"admin.auth.scopesHint": "逗号分隔的 scope 列表",
"admin.auth.providerId": "提供商 ID",
"admin.auth.providerIdHint": "仅限小写字母、数字和连字符",
"admin.auth.providerSaved": "提供商已保存",
"admin.auth.providerDeleted": "提供商已删除",
"admin.auth.statusEnabled": "已启用",
"admin.auth.statusDisabled": "已禁用",
"admin.auth.noProviders": "暂未配置 OAuth 提供商",
"admin.auth.deleteProviderTitle": "删除提供商",
"admin.auth.deleteProviderConfirm": "删除 OAuth 提供商 '{{name}}'?用户将无法再使用该提供商登录。",
"admin.auth.emailSection": "邮件配置",
"admin.auth.emailProvider": "提供商类型",
"admin.auth.emailSmtp": "SMTP",
"admin.auth.emailHttp": "HTTP API",
"admin.auth.emailFrom": "发件地址",
"admin.auth.smtpHost": "主机",
"admin.auth.smtpPort": "端口",
"admin.auth.smtpUser": "用户名",
"admin.auth.smtpPass": "密码",
"admin.auth.smtpSecure": "安全连接 (TLS)",
"admin.auth.httpUrl": "API URL",
"admin.auth.httpApiKey": "API Key",
"admin.auth.testEmail": "发送测试邮件",
"admin.auth.testEmailTo": "收件邮箱",
"admin.auth.testEmailSent": "测试邮件已发送",
"admin.auth.testEmailFailed": "测试邮件发送失败",
"admin.auth.emailSaved": "邮件配置已保存",
"admin.auth.emailNotConfigured": "邮件尚未配置",
"admin.storages.title": "存储",
"admin.storages.placeholder": "在此配置您的 S3 兼容存储后端。",
"admin.storages.add": "添加存储",
+81 -1
View File
@@ -1,6 +1,18 @@
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
import type { AuthProvider, PaginatedResponse, Storage, StorageObject } from '@shared/types'
import { adminQuotas, authProviders, objects, storages, system, trash, userQuotas, users } from './rpc'
import {
adminQuotas,
authProviders,
emailConfig,
inviteCodes,
objects,
storages,
system,
trash,
userQuotas,
users,
} from './rpc'
export type { Storage, StorageObject }
@@ -187,6 +199,74 @@ export function listAuthProviders() {
return unwrap<{ items: AuthProvider[] }>(authProviders.index.$get())
}
export function listAdminAuthProviders() {
return unwrap<{ items: OAuthProviderConfig[] }>(authProviders.admin.$get())
}
export function upsertAuthProvider(providerId: string, data: Omit<OAuthProviderConfig, 'providerId'>) {
return unwrap<OAuthProviderConfig>(authProviders.admin[':providerId'].$put({ param: { providerId }, json: data }))
}
export function deleteAuthProvider(providerId: string) {
return unwrap<{ providerId: string; deleted: boolean }>(
authProviders.admin[':providerId'].$delete({ param: { providerId } }),
)
}
// Invite Codes API
export interface InviteCode {
id: string
code: string
createdBy: string
usedBy: string | null
usedAt: string | null
expiresAt: string | null
createdAt: string
}
export function listInviteCodes(page = 1, pageSize = 20) {
return unwrap<{ items: InviteCode[]; total: number }>(inviteCodes.index.$get({ query: { page, pageSize } }))
}
export function generateInviteCodes(count: number, expiresInDays?: number) {
const body: { count: number; expiresInDays?: number } = { count }
if (expiresInDays !== undefined) body.expiresInDays = expiresInDays
return unwrap<{ codes: InviteCode[] }>(inviteCodes.index.$post({ json: body }))
}
export function deleteInviteCode(id: string) {
return unwrap<{ id: string; deleted: boolean }>(inviteCodes[':id'].$delete({ param: { id } }))
}
// Email Config API
export interface SmtpEmailConfig {
provider: 'smtp'
from: string
smtp: { host: string; port: number; user: string; pass: string; secure: boolean }
}
export interface HttpEmailConfig {
provider: 'http'
from: string
http: { url: string; apiKey: string }
}
export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig
export function getEmailConfig() {
return unwrap<EmailConfigData | { provider: null }>(emailConfig.index.$get())
}
export function saveEmailConfig(data: EmailConfigData) {
return unwrap<{ success: boolean }>(emailConfig.index.$put({ json: data }))
}
export function testEmail(to: string) {
return unwrap<{ success: boolean; error?: string }>(emailConfig.test.$post({ json: { to } }))
}
// Auth API — Better Auth passthrough, not typed via Hono RPC
export async function getSession(): Promise<{ session: unknown; user: unknown } | null> {
const res = await fetch('/api/auth/get-session', { credentials: 'include' })
+4
View File
@@ -1,6 +1,8 @@
import type {
AdminInviteCodesRoute,
AdminQuotasRoute,
AuthProvidersRoute,
EmailConfigRoute,
ObjectsRoute,
StoragesRoute,
SystemRoute,
@@ -20,3 +22,5 @@ export const adminQuotas = hc<AdminQuotasRoute>('/api/admin/quotas', opts)
export const userQuotas = hc<UserQuotasRoute>('/api/quotas', opts)
export const system = hc<SystemRoute>('/api/system', opts)
export const authProviders = hc<AuthProvidersRoute>('/api/auth-providers', opts)
export const inviteCodes = hc<AdminInviteCodesRoute>('/api/admin/invite-codes', opts)
export const emailConfig = hc<EmailConfigRoute>('/api/admin/email-config', opts)
+22
View File
@@ -21,6 +21,7 @@ import { Route as AuthenticatedRecycleBinIndexRouteImport } from './routes/_auth
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index'
import { Route as AuthenticatedAdminStoragesIndexRouteImport } from './routes/_authenticated/admin/storages/index'
import { Route as AuthenticatedAdminSettingsAuthRouteImport } from './routes/_authenticated/admin/settings/auth'
import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index'
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
@@ -87,6 +88,12 @@ const AuthenticatedAdminStoragesIndexRoute =
path: '/storages/',
getParentRoute: () => AuthenticatedAdminRouteRoute,
} as any)
const AuthenticatedAdminSettingsAuthRoute =
AuthenticatedAdminSettingsAuthRouteImport.update({
id: '/settings/auth',
path: '/settings/auth',
getParentRoute: () => AuthenticatedAdminRouteRoute,
} as any)
const AuthenticatedAdminSettingsIndexRoute =
AuthenticatedAdminSettingsIndexRouteImport.update({
id: '/settings/',
@@ -104,6 +111,7 @@ export interface FileRoutesByFullPath {
'/settings/': typeof AuthenticatedSettingsIndexRoute
'/storages/': typeof AuthenticatedStoragesIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
'/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
'/admin/users/': typeof AuthenticatedAdminUsersIndexRoute
@@ -118,6 +126,7 @@ export interface FileRoutesByTo {
'/settings': typeof AuthenticatedSettingsIndexRoute
'/storages': typeof AuthenticatedStoragesIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/admin/settings': typeof AuthenticatedAdminSettingsIndexRoute
'/admin/storages': typeof AuthenticatedAdminStoragesIndexRoute
'/admin/users': typeof AuthenticatedAdminUsersIndexRoute
@@ -134,6 +143,7 @@ export interface FileRoutesById {
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
'/_authenticated/storages/': typeof AuthenticatedStoragesIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/_authenticated/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
'/_authenticated/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute
'/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute
@@ -150,6 +160,7 @@ export interface FileRouteTypes {
| '/settings/'
| '/storages/'
| '/users/'
| '/admin/settings/auth'
| '/admin/settings/'
| '/admin/storages/'
| '/admin/users/'
@@ -164,6 +175,7 @@ export interface FileRouteTypes {
| '/settings'
| '/storages'
| '/users'
| '/admin/settings/auth'
| '/admin/settings'
| '/admin/storages'
| '/admin/users'
@@ -179,6 +191,7 @@ export interface FileRouteTypes {
| '/_authenticated/settings/'
| '/_authenticated/storages/'
| '/_authenticated/users/'
| '/_authenticated/admin/settings/auth'
| '/_authenticated/admin/settings/'
| '/_authenticated/admin/storages/'
| '/_authenticated/admin/users/'
@@ -276,6 +289,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAdminStoragesIndexRouteImport
parentRoute: typeof AuthenticatedAdminRouteRoute
}
'/_authenticated/admin/settings/auth': {
id: '/_authenticated/admin/settings/auth'
path: '/settings/auth'
fullPath: '/admin/settings/auth'
preLoaderRoute: typeof AuthenticatedAdminSettingsAuthRouteImport
parentRoute: typeof AuthenticatedAdminRouteRoute
}
'/_authenticated/admin/settings/': {
id: '/_authenticated/admin/settings/'
path: '/settings'
@@ -287,6 +307,7 @@ declare module '@tanstack/react-router' {
}
interface AuthenticatedAdminRouteRouteChildren {
AuthenticatedAdminSettingsAuthRoute: typeof AuthenticatedAdminSettingsAuthRoute
AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute
AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute
AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute
@@ -294,6 +315,7 @@ interface AuthenticatedAdminRouteRouteChildren {
const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren =
{
AuthenticatedAdminSettingsAuthRoute: AuthenticatedAdminSettingsAuthRoute,
AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute,
AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute,
AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute,
@@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { EmailConfigSection } from '@/components/admin/email-config-section'
import { InviteCodesSection } from '@/components/admin/invite-codes-section'
import { OAuthProvidersSection } from '@/components/admin/oauth-providers-section'
import { RegistrationModeSection } from '@/components/admin/registration-mode-section'
export const Route = createFileRoute('/_authenticated/admin/settings/auth')({
component: AuthSettingsPage,
})
function AuthSettingsPage() {
const { t } = useTranslation()
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">{t('admin.auth.title')}</h2>
<div className="max-w-4xl space-y-6">
<RegistrationModeSection />
<InviteCodesSection />
<OAuthProvidersSection />
<EmailConfigSection />
</div>
</div>
)
}