mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
feat(admin): redesign settings and rename auth to oauth
This commit is contained in:
@@ -19,7 +19,7 @@ const adminNavItems = [
|
||||
{ titleKey: 'admin.nav.overview', url: '/admin', icon: LayoutDashboard },
|
||||
{ titleKey: 'admin.nav.users', url: '/admin/users', icon: Users },
|
||||
{ titleKey: 'admin.nav.storages', url: '/admin/storages', icon: Database },
|
||||
{ titleKey: 'admin.nav.auth', url: '/admin/settings/auth', icon: KeyRound },
|
||||
{ titleKey: 'admin.nav.auth', url: '/admin/settings/oauth', icon: KeyRound },
|
||||
{ titleKey: 'admin.nav.email', url: '/admin/settings/email', icon: Mail },
|
||||
{ titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings },
|
||||
{ titleKey: 'admin.nav.licensing', url: '/admin/licensing', icon: BadgeCheck },
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { BrandingConfig, BrandingField } from '@shared/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, Upload } from 'lucide-react'
|
||||
import { Eye, ImageUp, Palette, Trash2, Upload } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { brandingQueryKey } from '@/components/branding/BrandingProvider'
|
||||
import { UpgradeHint } from '@/components/UpgradeHint'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useSiteOptions } from '@/hooks/use-site-options'
|
||||
@@ -73,6 +74,11 @@ function useBrandingFormState(initial: BrandingConfig): {
|
||||
function FileUploadField({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
emptyLabel,
|
||||
replaceLabel,
|
||||
resetLabel,
|
||||
disabled = false,
|
||||
accept,
|
||||
previewUrl,
|
||||
onFileChange,
|
||||
@@ -80,6 +86,11 @@ function FileUploadField({
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
emptyLabel: string
|
||||
replaceLabel: string
|
||||
resetLabel: string
|
||||
disabled?: boolean
|
||||
accept: string
|
||||
previewUrl: string | null
|
||||
onFileChange: (file: File | null) => void
|
||||
@@ -88,53 +99,94 @@ function FileUploadField({
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt={label} className="h-10 w-10 rounded border object-contain bg-muted/40" />
|
||||
)}
|
||||
<div className="rounded-2xl border border-border/60 bg-background p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/30">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt={label} className="h-10 w-10 rounded object-contain" />
|
||||
) : (
|
||||
<ImageUp className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="file"
|
||||
accept={accept}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{previewUrl ? 'Replace' : 'Upload'}
|
||||
</Button>
|
||||
{previewUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
<div className={`min-w-0 flex-1 space-y-3 ${disabled ? 'opacity-60' : ''}`}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{previewUrl ? replaceLabel : emptyLabel}
|
||||
</Button>
|
||||
{previewUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={onReset}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
{resetLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LivePreview({ branding }: { branding: BrandingConfig }) {
|
||||
const { t } = useTranslation()
|
||||
const { siteName } = useSiteOptions()
|
||||
const logoSrc = branding.logo_url ?? '/logo.svg'
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-sidebar p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Live Preview</p>
|
||||
<div className="flex items-center gap-2.5 border-b border-border/50 pb-3">
|
||||
<img src={logoSrc} alt={siteName} className="size-8 rounded object-contain" />
|
||||
<span className="text-lg font-semibold">{siteName}</span>
|
||||
<div className="rounded-[24px] border border-border/60 bg-gradient-to-br from-sidebar via-background to-muted/40 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>{t('admin.settings.branding.preview')}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 p-4">
|
||||
<div className="flex items-center gap-3 border-b border-border/60 pb-3">
|
||||
<img src={logoSrc} alt={siteName} className="size-9 rounded-lg object-contain" />
|
||||
<span className="text-lg font-semibold">{siteName}</span>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
{!branding.hide_powered_by && (
|
||||
<p className="text-xs text-center text-muted-foreground/70">{t('admin.settings.branding.poweredBy')}</p>
|
||||
)}
|
||||
{branding.hide_powered_by && (
|
||||
<p className="text-xs text-center text-muted-foreground/70">
|
||||
{t('admin.settings.branding.poweredHidden')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-background/70 p-4">
|
||||
<p className="text-sm font-medium">{t('admin.settings.branding.previewHintTitle')}</p>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{t('admin.settings.branding.previewHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!branding.hide_powered_by && <p className="text-xs text-muted-foreground/60 text-center">Powered by ZPan</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -142,6 +194,7 @@ function LivePreview({ branding }: { branding: BrandingConfig }) {
|
||||
// ─── Main Section ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function BrandingSection() {
|
||||
const { t } = useTranslation()
|
||||
const { hasFeature, isLoading: entitlementLoading } = useEntitlement()
|
||||
const { data: branding, isLoading: brandingLoading } = useQuery({
|
||||
queryKey: brandingQueryKey,
|
||||
@@ -150,21 +203,19 @@ export function BrandingSection() {
|
||||
})
|
||||
|
||||
if (entitlementLoading || brandingLoading) {
|
||||
return <div className="py-10 text-center text-sm text-muted-foreground">Loading branding settings...</div>
|
||||
}
|
||||
|
||||
if (!hasFeature('white_label')) {
|
||||
return <UpgradeHint feature="white_label" />
|
||||
return <div className="py-10 text-center text-sm text-muted-foreground">{t('common.loading')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandingForm
|
||||
disabled={!hasFeature('white_label')}
|
||||
initial={branding ?? { logo_url: null, favicon_url: null, wordmark_text: null, hide_powered_by: false }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandingForm({ initial }: { initial: BrandingConfig }) {
|
||||
function BrandingForm({ initial, disabled }: { initial: BrandingConfig; disabled: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { state, setLogoFile, setFaviconFile, setHidePoweredBy } = useBrandingFormState(initial)
|
||||
|
||||
@@ -177,7 +228,7 @@ function BrandingForm({ initial }: { initial: BrandingConfig }) {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: brandingQueryKey })
|
||||
toast.success('Branding saved')
|
||||
toast.success(t('admin.settings.branding.saved'))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -189,7 +240,7 @@ function BrandingForm({ initial }: { initial: BrandingConfig }) {
|
||||
if (field === 'logo') setLogoFile(null)
|
||||
if (field === 'favicon') setFaviconFile(null)
|
||||
if (field === 'hide_powered_by') setHidePoweredBy(false)
|
||||
toast.success(`${field} reset to default`)
|
||||
toast.success(t('admin.settings.branding.resetSuccess', { field: t(`admin.settings.branding.fields.${field}`) }))
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -202,44 +253,109 @@ function BrandingForm({ initial }: { initial: BrandingConfig }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_280px]">
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_340px]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border p-4 space-y-5">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Logo & Favicon</h3>
|
||||
<FileUploadField
|
||||
id="logo-upload"
|
||||
label="Logo"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
previewUrl={state.previewLogoUrl}
|
||||
onFileChange={setLogoFile}
|
||||
onReset={() => resetMutation.mutate('logo')}
|
||||
/>
|
||||
<FileUploadField
|
||||
id="favicon-upload"
|
||||
label="Favicon"
|
||||
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
|
||||
previewUrl={state.faviconPreviewUrl}
|
||||
onFileChange={setFaviconFile}
|
||||
onReset={() => resetMutation.mutate('favicon')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="hide-powered-by">Hide "Powered by ZPan"</Label>
|
||||
<p className="text-xs text-muted-foreground">Remove the footer credit from the sidebar.</p>
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-primary/10 p-2 text-primary">
|
||||
<Palette className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('admin.settings.branding.assetsTitle')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.branding.assetsDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch id="hide-powered-by" checked={state.hidePoweredBy} onCheckedChange={setHidePoweredBy} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!disabled && (
|
||||
<>
|
||||
<FileUploadField
|
||||
id="logo-upload"
|
||||
label={t('admin.settings.branding.logo')}
|
||||
hint={t('admin.settings.branding.logoHint')}
|
||||
emptyLabel={t('admin.settings.branding.upload')}
|
||||
replaceLabel={t('admin.settings.branding.replace')}
|
||||
resetLabel={t('admin.settings.branding.reset')}
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
previewUrl={state.previewLogoUrl}
|
||||
onFileChange={setLogoFile}
|
||||
onReset={() => resetMutation.mutate('logo')}
|
||||
/>
|
||||
<FileUploadField
|
||||
id="favicon-upload"
|
||||
label={t('admin.settings.branding.favicon')}
|
||||
hint={t('admin.settings.branding.faviconHint')}
|
||||
emptyLabel={t('admin.settings.branding.upload')}
|
||||
replaceLabel={t('admin.settings.branding.replace')}
|
||||
resetLabel={t('admin.settings.branding.reset')}
|
||||
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
|
||||
previewUrl={state.faviconPreviewUrl}
|
||||
onFileChange={setFaviconFile}
|
||||
onReset={() => resetMutation.mutate('favicon')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{disabled && (
|
||||
<div className="space-y-4 rounded-2xl border border-dashed border-border/70 bg-muted/25 p-4 text-sm text-muted-foreground">
|
||||
<p>{t('admin.settings.branding.lockedMessage')}</p>
|
||||
<FileUploadField
|
||||
id="logo-upload-disabled"
|
||||
label={t('admin.settings.branding.logo')}
|
||||
hint={t('admin.settings.branding.logoHint')}
|
||||
emptyLabel={t('admin.settings.branding.upload')}
|
||||
replaceLabel={t('admin.settings.branding.replace')}
|
||||
resetLabel={t('admin.settings.branding.reset')}
|
||||
disabled
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
previewUrl={state.previewLogoUrl}
|
||||
onFileChange={() => {}}
|
||||
onReset={() => {}}
|
||||
/>
|
||||
<FileUploadField
|
||||
id="favicon-upload-disabled"
|
||||
label={t('admin.settings.branding.favicon')}
|
||||
hint={t('admin.settings.branding.faviconHint')}
|
||||
emptyLabel={t('admin.settings.branding.upload')}
|
||||
replaceLabel={t('admin.settings.branding.replace')}
|
||||
resetLabel={t('admin.settings.branding.reset')}
|
||||
disabled
|
||||
accept="image/png,image/x-icon,image/vnd.microsoft.icon,image/svg+xml"
|
||||
previewUrl={state.faviconPreviewUrl}
|
||||
onFileChange={() => {}}
|
||||
onReset={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? 'Saving…' : 'Save branding'}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>{t('admin.settings.branding.visibilityTitle')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.branding.visibilityDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="hide-powered-by">{t('admin.settings.branding.hidePoweredBy')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.settings.branding.hidePoweredByHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-powered-by"
|
||||
checked={state.hidePoweredBy}
|
||||
disabled={disabled}
|
||||
onCheckedChange={setHidePoweredBy}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={() => saveMutation.mutate()} disabled={disabled || saveMutation.isPending}>
|
||||
{saveMutation.isPending ? t('admin.settings.branding.saving') : t('admin.settings.branding.save')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Preview</p>
|
||||
<div className="space-y-2 xl:sticky xl:top-4 xl:self-start">
|
||||
<LivePreview branding={previewBranding} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,9 +126,9 @@ export function OAuthProvidersSection() {
|
||||
const update = (patch: Partial<FormState>) => setForm((prev) => ({ ...prev, ...patch }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.auth.oauthSection')}</h3>
|
||||
<h2 className="text-xl font-semibold">{t('admin.auth.title')}</h2>
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
{t('admin.auth.addProvider')}
|
||||
</Button>
|
||||
@@ -137,43 +137,49 @@ export function OAuthProvidersSection() {
|
||||
{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>
|
||||
<div className="rounded-md border px-4 py-12 text-center text-muted-foreground">
|
||||
<p className="text-sm">{t('admin.auth.noProviders')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b bg-muted/50">
|
||||
<TableHead className="px-4 py-3">{t('admin.auth.provider')}</TableHead>
|
||||
<TableHead className="px-4 py-3">{t('admin.auth.providerType')}</TableHead>
|
||||
<TableHead className="px-4 py-3">{t('admin.auth.clientId')}</TableHead>
|
||||
<TableHead className="px-4 py-3">{t('admin.auth.enabled')}</TableHead>
|
||||
<TableHead className="px-4 py-3 text-right">{t('admin.auth.colActions')}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.providerId} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<TableCell className="px-4 py-3 font-medium">{providerName(p.providerId)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-sm">{p.type}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-sm font-mono">{p.clientId}</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${p.enabled ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{p.enabled ? t('admin.auth.statusEnabled') : t('admin.auth.statusDisabled')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-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>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
@@ -9,6 +9,7 @@ const ADMIN_NAV_AUTH_KEYS = ['admin.nav.auth']
|
||||
|
||||
const ADMIN_AUTH_KEYS = [
|
||||
'admin.auth.title',
|
||||
'admin.auth.description',
|
||||
'admin.auth.registrationSection',
|
||||
'admin.auth.registrationOpen',
|
||||
'admin.auth.registrationOpenDesc',
|
||||
@@ -106,12 +107,18 @@ describe('admin.auth locale keys — non-empty values', () => {
|
||||
})
|
||||
|
||||
describe('admin.auth locale keys — English values contract', () => {
|
||||
it('admin.nav.auth is "Auth"', () => {
|
||||
expect(enLocale['admin.nav.auth']).toBe('Auth')
|
||||
it('admin.nav.auth is "OAuth"', () => {
|
||||
expect(enLocale['admin.nav.auth']).toBe('OAuth')
|
||||
})
|
||||
|
||||
it('admin.auth.title is "Authentication"', () => {
|
||||
expect(enLocale['admin.auth.title']).toBe('Authentication')
|
||||
it('admin.auth.title is "OAuth Providers"', () => {
|
||||
expect(enLocale['admin.auth.title']).toBe('OAuth Providers')
|
||||
})
|
||||
|
||||
it('admin.auth.description is "Manage third-party sign-in providers and custom OIDC connections for this instance."', () => {
|
||||
expect(enLocale['admin.auth.description']).toBe(
|
||||
'Manage third-party sign-in providers and custom OIDC connections for this instance.',
|
||||
)
|
||||
})
|
||||
|
||||
it('admin.auth.registrationSection is "Registration Mode"', () => {
|
||||
@@ -243,25 +250,39 @@ 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')
|
||||
expect(i18n.t('admin.nav.auth')).toBe('OAuth')
|
||||
})
|
||||
|
||||
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('认证')
|
||||
expect(i18n.t('admin.nav.auth')).toBe('OAuth')
|
||||
})
|
||||
|
||||
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')
|
||||
expect(i18n.t('admin.auth.title')).toBe('OAuth Providers')
|
||||
})
|
||||
|
||||
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('身份认证')
|
||||
expect(i18n.t('admin.auth.title')).toBe('OAuth 提供商')
|
||||
})
|
||||
|
||||
it('translates admin.auth.description to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.auth.description')).toBe(
|
||||
'Manage third-party sign-in providers and custom OIDC connections for this instance.',
|
||||
)
|
||||
})
|
||||
|
||||
it('translates admin.auth.description to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.auth.description')).toBe('管理第三方登录提供商以及自定义 OIDC 连接。')
|
||||
})
|
||||
|
||||
it('translates admin.auth.registrationSection to English', async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'admin.settings.siteSection',
|
||||
'admin.settings.siteName',
|
||||
'admin.settings.siteDescription',
|
||||
'admin.settings.registrationTitle',
|
||||
'admin.settings.registrationLabel',
|
||||
'admin.settings.saved',
|
||||
]
|
||||
|
||||
@@ -62,6 +64,14 @@ describe('admin.settings locale keys — English values contract', () => {
|
||||
expect(enLocale['admin.settings.saved']).toBe('Settings saved')
|
||||
})
|
||||
|
||||
it('admin.settings.registrationTitle is "New user registration"', () => {
|
||||
expect(enLocale['admin.settings.registrationTitle']).toBe('New user registration')
|
||||
})
|
||||
|
||||
it('admin.settings.registrationLabel is "Allow new user sign-ups"', () => {
|
||||
expect(enLocale['admin.settings.registrationLabel']).toBe('Allow new user sign-ups')
|
||||
})
|
||||
|
||||
it('admin.nav.settings is "Settings"', () => {
|
||||
expect(enLocale['admin.nav.settings']).toBe('Settings')
|
||||
})
|
||||
@@ -128,6 +138,18 @@ describe('admin.settings locale keys — i18n runtime translation', () => {
|
||||
expect(i18n.t('admin.settings.saved')).toBe('设置已保存')
|
||||
})
|
||||
|
||||
it('translates admin.settings.registrationTitle to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t('admin.settings.registrationTitle')).toBe('New user registration')
|
||||
})
|
||||
|
||||
it('translates admin.settings.registrationTitle to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.settings.registrationTitle')).toBe('新用户注册')
|
||||
})
|
||||
|
||||
it('translates admin.nav.settings to English', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('en')
|
||||
|
||||
@@ -143,9 +143,69 @@
|
||||
"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.settings.eyebrow": "Administration",
|
||||
"admin.settings.subtitle": "Configure how your instance is presented to users and define the default storage policy for new organizations.",
|
||||
"admin.settings.saveAction": "Save changes",
|
||||
"admin.settings.metricIdentity": "Public identity",
|
||||
"admin.settings.metricIdentityHint": "This name and description appear across navigation, pages, and login surfaces.",
|
||||
"admin.settings.metricQuota": "Default quota",
|
||||
"admin.settings.metricExperience": "Experience",
|
||||
"admin.settings.metricExperienceValue": "Branded workspace",
|
||||
"admin.settings.metricExperienceHint": "Use custom assets and copy to make the instance feel like your own product.",
|
||||
"admin.settings.identityTitle": "Site identity",
|
||||
"admin.settings.identityDescription": "Set the primary name and short description users see across the product.",
|
||||
"admin.settings.registrationTitle": "New user registration",
|
||||
"admin.settings.registrationDescription": "Control whether new users are allowed to create accounts on this instance.",
|
||||
"admin.settings.registrationLabel": "Allow new user sign-ups",
|
||||
"admin.settings.registrationHintOpen": "New users can register directly from the sign-up page.",
|
||||
"admin.settings.registrationHintClosed": "New account creation is disabled for this instance.",
|
||||
"admin.settings.registrationUpgradeHint": "Open registration requires ZPan Pro.",
|
||||
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
|
||||
"admin.settings.siteNameHint": "Keep it short and recognizable. It will be reused in navigation and browser chrome.",
|
||||
"admin.settings.siteDescriptionPlaceholder": "Private file hosting for your team, customers, or community.",
|
||||
"admin.settings.siteDescriptionHint": "One or two sentences are enough. This copy should explain the purpose of the instance.",
|
||||
"admin.settings.quotaDescription": "Define the starting storage policy for every newly created organization.",
|
||||
"admin.settings.unlimited": "Unlimited",
|
||||
"admin.settings.previewTitle": "Live summary",
|
||||
"admin.settings.previewDescription": "A quick read on how the current configuration will feel before you save it.",
|
||||
"admin.settings.previewFallback": "Add a short description so users immediately understand what this instance is for.",
|
||||
"admin.settings.previewUrlLabel": "Primary address",
|
||||
"admin.settings.previewQuotaLabel": "New organization quota",
|
||||
"admin.settings.previewQuotaUnlimited": "New organizations will start without a storage cap.",
|
||||
"admin.settings.previewBrandingLabel": "Branding next step",
|
||||
"admin.settings.previewBrandingHint": "Use the branding section below to add your logo, favicon, and footer visibility rules.",
|
||||
"admin.settings.brandingTitle": "Branding",
|
||||
"admin.settings.brandingDescription": "Customize the visual identity of the instance so the admin and end-user surfaces feel cohesive.",
|
||||
"admin.settings.branding.preview": "Preview",
|
||||
"admin.settings.branding.poweredBy": "Powered by ZPan",
|
||||
"admin.settings.branding.poweredHidden": "Footer credit hidden",
|
||||
"admin.settings.branding.previewHintTitle": "What changes here",
|
||||
"admin.settings.branding.previewHint": "Logo and favicon updates affect shared surfaces immediately after saving.",
|
||||
"admin.settings.branding.assetsTitle": "Logo and favicon",
|
||||
"admin.settings.branding.assetsDescription": "Upload the assets users will see in the sidebar, browser tab, and public entry points.",
|
||||
"admin.settings.branding.logo": "Logo",
|
||||
"admin.settings.branding.logoHint": "Best for square or compact marks. PNG, JPG, WebP, or SVG.",
|
||||
"admin.settings.branding.favicon": "Favicon",
|
||||
"admin.settings.branding.faviconHint": "Used in browser tabs and bookmarks. Prefer a simple, high-contrast mark.",
|
||||
"admin.settings.branding.upload": "Upload",
|
||||
"admin.settings.branding.replace": "Replace",
|
||||
"admin.settings.branding.reset": "Reset",
|
||||
"admin.settings.branding.visibilityTitle": "Footer visibility",
|
||||
"admin.settings.branding.visibilityDescription": "Control whether the product credit remains visible in the app sidebar.",
|
||||
"admin.settings.branding.hidePoweredBy": "Hide \"Powered by ZPan\"",
|
||||
"admin.settings.branding.hidePoweredByHint": "Useful when the instance should look fully white-labeled.",
|
||||
"admin.settings.branding.lockedMessage": "Branding is available in ZPan Pro. The controls stay visible here, but they are locked until Pro is activated.",
|
||||
"admin.settings.branding.save": "Save branding",
|
||||
"admin.settings.branding.saving": "Saving…",
|
||||
"admin.settings.branding.saved": "Branding saved",
|
||||
"admin.settings.branding.resetSuccess": "{{field}} reset to default",
|
||||
"admin.settings.branding.fields.logo": "Logo",
|
||||
"admin.settings.branding.fields.favicon": "Favicon",
|
||||
"admin.settings.branding.fields.hide_powered_by": "Footer credit",
|
||||
"admin.nav.auth": "OAuth",
|
||||
"admin.nav.licensing": "Licensing",
|
||||
"admin.auth.title": "Authentication",
|
||||
"admin.auth.title": "OAuth Providers",
|
||||
"admin.auth.description": "Manage third-party sign-in providers and custom OIDC connections for this instance.",
|
||||
"admin.auth.registrationSection": "Registration Mode",
|
||||
"admin.auth.registrationOpen": "Open",
|
||||
"admin.auth.registrationOpenDesc": "Anyone can sign up",
|
||||
|
||||
@@ -143,9 +143,69 @@
|
||||
"admin.settings.defaultOrgQuota": "默认配额 (GB)",
|
||||
"admin.settings.defaultOrgQuotaHint": "新用户的存储配额,0 表示不限制。",
|
||||
"admin.settings.saved": "设置已保存",
|
||||
"admin.nav.auth": "认证",
|
||||
"admin.settings.eyebrow": "管理后台",
|
||||
"admin.settings.subtitle": "配置站点对外呈现的方式,并为新组织设定默认的存储策略。",
|
||||
"admin.settings.saveAction": "保存更改",
|
||||
"admin.settings.metricIdentity": "公开标识",
|
||||
"admin.settings.metricIdentityHint": "名称和描述会出现在导航、页面标题和登录等对外界面中。",
|
||||
"admin.settings.metricQuota": "默认配额",
|
||||
"admin.settings.metricExperience": "体验定位",
|
||||
"admin.settings.metricExperienceValue": "品牌化工作区",
|
||||
"admin.settings.metricExperienceHint": "通过品牌素材和文案,让实例更像你自己的产品,而不是默认安装页。",
|
||||
"admin.settings.identityTitle": "站点标识",
|
||||
"admin.settings.identityDescription": "设置用户在整个产品中都会看到的站点名称和简短介绍。",
|
||||
"admin.settings.registrationTitle": "新用户注册",
|
||||
"admin.settings.registrationDescription": "控制这个实例是否允许新用户自行创建账号。",
|
||||
"admin.settings.registrationLabel": "允许新用户注册",
|
||||
"admin.settings.registrationHintOpen": "新用户可以直接从注册页创建账号。",
|
||||
"admin.settings.registrationHintClosed": "当前实例已关闭新账号注册。",
|
||||
"admin.settings.registrationUpgradeHint": "开放注册需要 ZPan Pro。",
|
||||
"admin.settings.siteNamePlaceholder": "ZPan Workspace",
|
||||
"admin.settings.siteNameHint": "保持简短且易识别,它会被复用到导航和浏览器标题等位置。",
|
||||
"admin.settings.siteDescriptionPlaceholder": "为你的团队、客户或社区提供私有文件托管服务。",
|
||||
"admin.settings.siteDescriptionHint": "一两句话就够,重点说明这个实例是做什么的。",
|
||||
"admin.settings.quotaDescription": "定义每个新建组织的初始存储策略。",
|
||||
"admin.settings.unlimited": "不限",
|
||||
"admin.settings.previewTitle": "实时概览",
|
||||
"admin.settings.previewDescription": "在保存之前,快速查看当前配置给用户呈现出来的大致感觉。",
|
||||
"admin.settings.previewFallback": "添加一段简短描述,让用户一进来就知道这个实例是做什么的。",
|
||||
"admin.settings.previewUrlLabel": "主访问地址",
|
||||
"admin.settings.previewQuotaLabel": "新组织默认配额",
|
||||
"admin.settings.previewQuotaUnlimited": "新组织创建后默认不设存储上限。",
|
||||
"admin.settings.previewBrandingLabel": "品牌下一步",
|
||||
"admin.settings.previewBrandingHint": "继续在下方配置 Logo、Favicon 和底部署名显示规则。",
|
||||
"admin.settings.brandingTitle": "品牌设置",
|
||||
"admin.settings.brandingDescription": "统一实例的视觉识别,让管理后台和最终用户界面都更完整一致。",
|
||||
"admin.settings.branding.preview": "预览",
|
||||
"admin.settings.branding.poweredBy": "Powered by ZPan",
|
||||
"admin.settings.branding.poweredHidden": "底部署名已隐藏",
|
||||
"admin.settings.branding.previewHintTitle": "这里会变化什么",
|
||||
"admin.settings.branding.previewHint": "保存后,Logo 和 favicon 会立即影响到共享界面和应用入口。",
|
||||
"admin.settings.branding.assetsTitle": "Logo 与 Favicon",
|
||||
"admin.settings.branding.assetsDescription": "上传会出现在侧边栏、浏览器标签页和公共入口中的品牌素材。",
|
||||
"admin.settings.branding.logo": "Logo",
|
||||
"admin.settings.branding.logoHint": "适合方形或紧凑型标识。支持 PNG、JPG、WebP、SVG。",
|
||||
"admin.settings.branding.favicon": "Favicon",
|
||||
"admin.settings.branding.faviconHint": "用于浏览器标签页和书签,建议使用高对比、简洁的图形。",
|
||||
"admin.settings.branding.upload": "上传",
|
||||
"admin.settings.branding.replace": "替换",
|
||||
"admin.settings.branding.reset": "重置",
|
||||
"admin.settings.branding.visibilityTitle": "底部署名显示",
|
||||
"admin.settings.branding.visibilityDescription": "控制应用侧边栏中的产品署名是否继续显示。",
|
||||
"admin.settings.branding.hidePoweredBy": "隐藏 “Powered by ZPan”",
|
||||
"admin.settings.branding.hidePoweredByHint": "当你希望实例更接近完整白标体验时,这个选项会有用。",
|
||||
"admin.settings.branding.lockedMessage": "Branding 仅在 ZPan Pro 中可用。这里仍然会展示相关设置,但在激活 Pro 之前会保持禁用。",
|
||||
"admin.settings.branding.save": "保存品牌设置",
|
||||
"admin.settings.branding.saving": "保存中…",
|
||||
"admin.settings.branding.saved": "品牌设置已保存",
|
||||
"admin.settings.branding.resetSuccess": "{{field}} 已重置为默认值",
|
||||
"admin.settings.branding.fields.logo": "Logo",
|
||||
"admin.settings.branding.fields.favicon": "Favicon",
|
||||
"admin.settings.branding.fields.hide_powered_by": "底部署名",
|
||||
"admin.nav.auth": "OAuth",
|
||||
"admin.nav.licensing": "授权",
|
||||
"admin.auth.title": "身份认证",
|
||||
"admin.auth.title": "OAuth 提供商",
|
||||
"admin.auth.description": "管理第三方登录提供商以及自定义 OIDC 连接。",
|
||||
"admin.auth.registrationSection": "注册模式",
|
||||
"admin.auth.registrationOpen": "开放",
|
||||
"admin.auth.registrationOpenDesc": "任何人都可以注册",
|
||||
|
||||
+22
-22
@@ -41,8 +41,8 @@ import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_a
|
||||
import { Route as AuthenticatedTeamsTeamIdSettingsRouteImport } from './routes/_authenticated/teams/$teamId/settings'
|
||||
import { Route as AuthenticatedTeamsTeamIdMembersRouteImport } from './routes/_authenticated/teams/$teamId/members'
|
||||
import { Route as AuthenticatedTeamsTeamIdActivityRouteImport } from './routes/_authenticated/teams/$teamId/activity'
|
||||
import { Route as AuthenticatedAdminSettingsOauthRouteImport } from './routes/_authenticated/admin/settings/oauth'
|
||||
import { Route as AuthenticatedAdminSettingsEmailRouteImport } from './routes/_authenticated/admin/settings/email'
|
||||
import { Route as AuthenticatedAdminSettingsAuthRouteImport } from './routes/_authenticated/admin/settings/auth'
|
||||
|
||||
const SRouteRoute = SRouteRouteImport.update({
|
||||
id: '/s',
|
||||
@@ -222,18 +222,18 @@ const AuthenticatedTeamsTeamIdActivityRoute =
|
||||
path: '/activity',
|
||||
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminSettingsOauthRoute =
|
||||
AuthenticatedAdminSettingsOauthRouteImport.update({
|
||||
id: '/settings/oauth',
|
||||
path: '/settings/oauth',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminSettingsEmailRoute =
|
||||
AuthenticatedAdminSettingsEmailRouteImport.update({
|
||||
id: '/settings/email',
|
||||
path: '/settings/email',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedAdminSettingsAuthRoute =
|
||||
AuthenticatedAdminSettingsAuthRouteImport.update({
|
||||
id: '/settings/auth',
|
||||
path: '/settings/auth',
|
||||
getParentRoute: () => AuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
@@ -260,8 +260,8 @@ export interface FileRoutesByFullPath {
|
||||
'/teams/': typeof AuthenticatedTeamsIndexRoute
|
||||
'/trash/': typeof AuthenticatedTrashIndexRoute
|
||||
'/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
|
||||
'/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
|
||||
'/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
|
||||
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -292,8 +292,8 @@ export interface FileRoutesByTo {
|
||||
'/teams': typeof AuthenticatedTeamsIndexRoute
|
||||
'/trash': typeof AuthenticatedTrashIndexRoute
|
||||
'/users': typeof AuthenticatedUsersIndexRoute
|
||||
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
|
||||
'/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
|
||||
'/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
|
||||
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -329,8 +329,8 @@ export interface FileRoutesById {
|
||||
'/_authenticated/teams/': typeof AuthenticatedTeamsIndexRoute
|
||||
'/_authenticated/trash/': typeof AuthenticatedTrashIndexRoute
|
||||
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
|
||||
'/_authenticated/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
|
||||
'/_authenticated/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
|
||||
'/_authenticated/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
|
||||
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/_authenticated/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/_authenticated/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -366,8 +366,8 @@ export interface FileRouteTypes {
|
||||
| '/teams/'
|
||||
| '/trash/'
|
||||
| '/users/'
|
||||
| '/admin/settings/auth'
|
||||
| '/admin/settings/email'
|
||||
| '/admin/settings/oauth'
|
||||
| '/teams/$teamId/activity'
|
||||
| '/teams/$teamId/members'
|
||||
| '/teams/$teamId/settings'
|
||||
@@ -398,8 +398,8 @@ export interface FileRouteTypes {
|
||||
| '/teams'
|
||||
| '/trash'
|
||||
| '/users'
|
||||
| '/admin/settings/auth'
|
||||
| '/admin/settings/email'
|
||||
| '/admin/settings/oauth'
|
||||
| '/teams/$teamId/activity'
|
||||
| '/teams/$teamId/members'
|
||||
| '/teams/$teamId/settings'
|
||||
@@ -434,8 +434,8 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/teams/'
|
||||
| '/_authenticated/trash/'
|
||||
| '/_authenticated/users/'
|
||||
| '/_authenticated/admin/settings/auth'
|
||||
| '/_authenticated/admin/settings/email'
|
||||
| '/_authenticated/admin/settings/oauth'
|
||||
| '/_authenticated/teams/$teamId/activity'
|
||||
| '/_authenticated/teams/$teamId/members'
|
||||
| '/_authenticated/teams/$teamId/settings'
|
||||
@@ -679,6 +679,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedTeamsTeamIdActivityRouteImport
|
||||
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/settings/oauth': {
|
||||
id: '/_authenticated/admin/settings/oauth'
|
||||
path: '/settings/oauth'
|
||||
fullPath: '/admin/settings/oauth'
|
||||
preLoaderRoute: typeof AuthenticatedAdminSettingsOauthRouteImport
|
||||
parentRoute: typeof AuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_authenticated/admin/settings/email': {
|
||||
id: '/_authenticated/admin/settings/email'
|
||||
path: '/settings/email'
|
||||
@@ -686,21 +693,14 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAdminSettingsEmailRouteImport
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAdminRouteRouteChildren {
|
||||
AuthenticatedAdminLicensingRoute: typeof AuthenticatedAdminLicensingRoute
|
||||
AuthenticatedAdminIndexRoute: typeof AuthenticatedAdminIndexRoute
|
||||
AuthenticatedAdminSettingsAuthRoute: typeof AuthenticatedAdminSettingsAuthRoute
|
||||
AuthenticatedAdminSettingsEmailRoute: typeof AuthenticatedAdminSettingsEmailRoute
|
||||
AuthenticatedAdminSettingsOauthRoute: typeof AuthenticatedAdminSettingsOauthRoute
|
||||
AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute
|
||||
AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute
|
||||
AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute
|
||||
@@ -710,8 +710,8 @@ const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren
|
||||
{
|
||||
AuthenticatedAdminLicensingRoute: AuthenticatedAdminLicensingRoute,
|
||||
AuthenticatedAdminIndexRoute: AuthenticatedAdminIndexRoute,
|
||||
AuthenticatedAdminSettingsAuthRoute: AuthenticatedAdminSettingsAuthRoute,
|
||||
AuthenticatedAdminSettingsEmailRoute: AuthenticatedAdminSettingsEmailRoute,
|
||||
AuthenticatedAdminSettingsOauthRoute: AuthenticatedAdminSettingsOauthRoute,
|
||||
AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute,
|
||||
AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute,
|
||||
AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute,
|
||||
|
||||
@@ -104,9 +104,9 @@ function OverviewPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" asChild>
|
||||
<Link to="/admin/settings/auth">
|
||||
<Link to="/admin/settings/oauth">
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Auth Configuration
|
||||
OAuth Configuration
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { SignupMode } from '@shared/constants'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Globe2, HardDrive } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,12 +10,14 @@ import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
import { BrandingSection } from '@/components/admin/branding-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { siteOptionsQueryKey, useSiteOptions } from '@/hooks/use-site-options'
|
||||
import { useEntitlement } from '@/hooks/useEntitlement'
|
||||
import { setSystemOption } from '@/lib/api'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
@@ -34,6 +38,7 @@ const settingsSchema = z.object({
|
||||
siteDescription: z.string(),
|
||||
quotaValue: z.coerce.number().min(0),
|
||||
quotaUnit: z.enum(['MB', 'GB']),
|
||||
registrationsEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>
|
||||
@@ -41,18 +46,32 @@ type SettingsFormValues = z.infer<typeof settingsSchema>
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { siteName, siteDescription, defaultOrgQuota: quotaBytes, isLoading } = useSiteOptions()
|
||||
const { siteName, siteDescription, defaultOrgQuota: quotaBytes, authSignupMode, isLoading } = useSiteOptions()
|
||||
const { hasFeature } = useEntitlement()
|
||||
const hasOpenRegistration = hasFeature('open_registration')
|
||||
|
||||
const form = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: { siteName: '', siteDescription: '', quotaValue: 0, quotaUnit: 'MB' as Unit },
|
||||
defaultValues: {
|
||||
siteName: '',
|
||||
siteDescription: '',
|
||||
quotaValue: 0,
|
||||
quotaUnit: 'MB' as Unit,
|
||||
registrationsEnabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
const { value, unit } = bytesToDisplay(quotaBytes)
|
||||
form.reset({ siteName, siteDescription, quotaValue: value, quotaUnit: unit })
|
||||
}, [isLoading, siteName, siteDescription, quotaBytes, form])
|
||||
form.reset({
|
||||
siteName,
|
||||
siteDescription,
|
||||
quotaValue: value,
|
||||
quotaUnit: unit,
|
||||
registrationsEnabled: authSignupMode === SignupMode.OPEN,
|
||||
})
|
||||
}, [isLoading, siteName, siteDescription, quotaBytes, authSignupMode, form])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: SettingsFormValues) => {
|
||||
@@ -60,6 +79,7 @@ function SettingsPage() {
|
||||
await setSystemOption('site_description', values.siteDescription, true)
|
||||
const bytes = values.quotaValue * UNITS[values.quotaUnit]
|
||||
await setSystemOption('default_org_quota', String(bytes), false)
|
||||
await setSystemOption('auth_signup_mode', values.registrationsEnabled ? SignupMode.OPEN : SignupMode.CLOSED, true)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: siteOptionsQueryKey })
|
||||
@@ -70,6 +90,11 @@ function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const quotaValue = form.watch('quotaValue')
|
||||
const quotaUnit = form.watch('quotaUnit')
|
||||
const quotaDisplayBytes = quotaValue * UNITS[quotaUnit]
|
||||
const registrationsEnabled = form.watch('registrationsEnabled')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
@@ -80,76 +105,147 @@ function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">{t('admin.settings.title')}</h2>
|
||||
|
||||
<form onSubmit={form.handleSubmit((v) => saveMutation.mutate(v))} className="max-w-2xl space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.settings.siteSection')}</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="siteName">{t('admin.settings.siteName')}</Label>
|
||||
<Input id="siteName" {...form.register('siteName')} />
|
||||
{form.formState.errors.siteName && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.siteName.message}</p>
|
||||
)}
|
||||
<form id="site-settings-form" onSubmit={form.handleSubmit((v) => saveMutation.mutate(v))} className="space-y-6">
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-primary/10 p-2 text-primary">
|
||||
<Globe2 className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="siteDescription">{t('admin.settings.siteDescription')}</Label>
|
||||
<Textarea id="siteDescription" rows={3} {...form.register('siteDescription')} />
|
||||
{form.formState.errors.siteDescription && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.siteDescription.message}</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('admin.settings.identityTitle')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.identityDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteName">{t('admin.settings.siteName')}</Label>
|
||||
<Input
|
||||
id="siteName"
|
||||
placeholder={t('admin.settings.siteNamePlaceholder')}
|
||||
{...form.register('siteName')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.settings.siteNameHint')}</p>
|
||||
{form.formState.errors.siteName && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.siteName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t('admin.settings.quotaSection')}</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteDescription">{t('admin.settings.siteDescription')}</Label>
|
||||
<Textarea
|
||||
id="siteDescription"
|
||||
rows={4}
|
||||
placeholder={t('admin.settings.siteDescriptionPlaceholder')}
|
||||
{...form.register('siteDescription')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.settings.siteDescriptionHint')}</p>
|
||||
{form.formState.errors.siteDescription && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.siteDescription.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quotaValue">{t('admin.settings.defaultOrgQuota')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="quotaValue"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
{...form.register('quotaValue')}
|
||||
/>
|
||||
<Select value={form.watch('quotaUnit')} onValueChange={(v) => form.setValue('quotaUnit', v as Unit)}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MB">MB</SelectItem>
|
||||
<SelectItem value="GB">GB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
= {formatSize(form.watch('quotaValue') * UNITS[form.watch('quotaUnit')])}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-amber-500/10 p-2 text-amber-600">
|
||||
<Globe2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('admin.settings.registrationTitle')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.registrationDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border/60 bg-background p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="registrationsEnabled">{t('admin.settings.registrationLabel')}</Label>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{registrationsEnabled
|
||||
? t('admin.settings.registrationHintOpen')
|
||||
: t('admin.settings.registrationHintClosed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.settings.defaultOrgQuotaHint')}</p>
|
||||
{form.formState.errors.quotaValue && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.quotaValue.message}</p>
|
||||
{!hasOpenRegistration && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{t('admin.settings.registrationUpgradeHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="registrationsEnabled"
|
||||
checked={registrationsEnabled}
|
||||
disabled={!hasOpenRegistration}
|
||||
onCheckedChange={(checked) => form.setValue('registrationsEnabled', checked, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-emerald-500/10 p-2 text-emerald-600">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{t('admin.settings.quotaSection')}</CardTitle>
|
||||
<CardDescription>{t('admin.settings.quotaDescription')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quotaValue">{t('admin.settings.defaultOrgQuota')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="quotaValue"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
{...form.register('quotaValue')}
|
||||
/>
|
||||
<Select value={quotaUnit} onValueChange={(v) => form.setValue('quotaUnit', v as Unit)}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MB">MB</SelectItem>
|
||||
<SelectItem value="GB">GB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/30 px-4 py-3">
|
||||
<p className="text-sm font-medium">
|
||||
{quotaDisplayBytes === 0 ? t('admin.settings.unlimited') : formatSize(quotaDisplayBytes)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{t('admin.settings.defaultOrgQuotaHint')}
|
||||
</p>
|
||||
</div>
|
||||
{form.formState.errors.quotaValue && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.quotaValue.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Branding</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold">{t('admin.settings.brandingTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.settings.brandingDescription')}</p>
|
||||
</div>
|
||||
<BrandingSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { OAuthProvidersSection } from '@/components/admin/oauth-providers-section'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/settings/oauth')({
|
||||
component: AuthSettingsPage,
|
||||
})
|
||||
|
||||
function AuthSettingsPage() {
|
||||
return <OAuthProvidersSection />
|
||||
}
|
||||
Reference in New Issue
Block a user