mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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('邮件配置已保存')
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user