feat(admin): redesign settings and rename auth to oauth

This commit is contained in:
saltbo
2026-04-27 19:08:17 -04:00
parent 4872f97d9f
commit fb3b15a38c
12 changed files with 599 additions and 232 deletions
+1 -1
View File
@@ -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 },
+187 -71
View File
@@ -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}>
+29 -8
View File
@@ -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 () => {
+22
View File
@@ -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')
+62 -2
View File
@@ -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",
+62 -2
View File
@@ -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
View File
@@ -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,
+2 -2
View File
@@ -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 />
}