From fb3b15a38ce70585d60bb86f1df87aa659aa7fed Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 27 Apr 2026 19:08:17 -0400 Subject: [PATCH] feat(admin): redesign settings and rename auth to oauth --- src/components/admin/admin-sidebar.tsx | 2 +- src/components/admin/branding-section.tsx | 258 +++++++++++++----- .../admin/oauth-providers-section.tsx | 80 +++--- src/i18n/admin-auth-locale.test.ts | 37 ++- src/i18n/admin-settings-locale.test.ts | 22 ++ src/i18n/locales/en.json | 64 ++++- src/i18n/locales/zh.json | 64 ++++- src/routeTree.gen.ts | 44 +-- src/routes/_authenticated/admin/index.tsx | 4 +- .../_authenticated/admin/settings/auth.tsx | 24 -- .../_authenticated/admin/settings/index.tsx | 222 ++++++++++----- .../_authenticated/admin/settings/oauth.tsx | 10 + 12 files changed, 599 insertions(+), 232 deletions(-) delete mode 100644 src/routes/_authenticated/admin/settings/auth.tsx create mode 100644 src/routes/_authenticated/admin/settings/oauth.tsx diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 0644307e..12c873b5 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -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 }, diff --git a/src/components/admin/branding-section.tsx b/src/components/admin/branding-section.tsx index 6b582d71..8d1d8779 100644 --- a/src/components/admin/branding-section.tsx +++ b/src/components/admin/branding-section.tsx @@ -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(null) return ( -
- -
- {previewUrl && ( - {label} - )} +
+
+
+ {previewUrl ? ( + {label} + ) : ( + + )} +
onFileChange(e.target.files?.[0] ?? null)} /> - - {previewUrl && ( - - )} +
+
+ +

{hint}

+
+
+ + {previewUrl && ( + + )} +
+
) } function LivePreview({ branding }: { branding: BrandingConfig }) { + const { t } = useTranslation() const { siteName } = useSiteOptions() const logoSrc = branding.logo_url ?? '/logo.svg' return ( -
-

Live Preview

-
- {siteName} - {siteName} +
+
+ + {t('admin.settings.branding.preview')} +
+
+
+
+ {siteName} + {siteName} +
+
+ {!branding.hide_powered_by && ( +

{t('admin.settings.branding.poweredBy')}

+ )} + {branding.hide_powered_by && ( +

+ {t('admin.settings.branding.poweredHidden')} +

+ )} +
+
+ +
+

{t('admin.settings.branding.previewHintTitle')}

+

{t('admin.settings.branding.previewHint')}

+
- {!branding.hide_powered_by &&

Powered by ZPan

}
) } @@ -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
Loading branding settings...
- } - - if (!hasFeature('white_label')) { - return + return
{t('common.loading')}
} return ( ) } -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 ( -
+
-
-

Logo & Favicon

- resetMutation.mutate('logo')} - /> - resetMutation.mutate('favicon')} - /> -
- -
-
-
- -

Remove the footer credit from the sidebar.

+ + +
+
+ +
+
+ {t('admin.settings.branding.assetsTitle')} + {t('admin.settings.branding.assetsDescription')} +
- -
-
+ + + {!disabled && ( + <> + resetMutation.mutate('logo')} + /> + resetMutation.mutate('favicon')} + /> + + )} + {disabled && ( +
+

{t('admin.settings.branding.lockedMessage')}

+ {}} + onReset={() => {}} + /> + {}} + onReset={() => {}} + /> +
+ )} +
+ -
-
-

Preview

+
diff --git a/src/components/admin/oauth-providers-section.tsx b/src/components/admin/oauth-providers-section.tsx index 8b64f8a9..b45ec057 100644 --- a/src/components/admin/oauth-providers-section.tsx +++ b/src/components/admin/oauth-providers-section.tsx @@ -126,9 +126,9 @@ export function OAuthProvidersSection() { const update = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })) return ( -
+
-

{t('admin.auth.oauthSection')}

+

{t('admin.auth.title')}

@@ -137,43 +137,49 @@ export function OAuthProvidersSection() { {isLoading ? (

{t('common.loading')}

) : providers.length === 0 ? ( -

{t('admin.auth.noProviders')}

+
+

{t('admin.auth.noProviders')}

+
) : ( - - - - {t('admin.auth.provider')} - {t('admin.auth.providerType')} - {t('admin.auth.clientId')} - {t('admin.auth.enabled')} - {t('admin.auth.colActions')} - - - - {providers.map((p) => ( - - {providerName(p.providerId)} - {p.type} - {p.clientId} - - - {p.enabled ? t('admin.auth.statusEnabled') : t('admin.auth.statusDisabled')} - - - - - - +
+
+ + + {t('admin.auth.provider')} + {t('admin.auth.providerType')} + {t('admin.auth.clientId')} + {t('admin.auth.enabled')} + {t('admin.auth.colActions')} - ))} - -
+ + + {providers.map((p) => ( + + {providerName(p.providerId)} + {p.type} + {p.clientId} + + + {p.enabled ? t('admin.auth.statusEnabled') : t('admin.auth.statusDisabled')} + + + +
+ + +
+
+
+ ))} +
+ +
)} diff --git a/src/i18n/admin-auth-locale.test.ts b/src/i18n/admin-auth-locale.test.ts index bc78847a..e6d63fe9 100644 --- a/src/i18n/admin-auth-locale.test.ts +++ b/src/i18n/admin-auth-locale.test.ts @@ -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 () => { diff --git a/src/i18n/admin-settings-locale.test.ts b/src/i18n/admin-settings-locale.test.ts index fed4e342..7894b7c6 100644 --- a/src/i18n/admin-settings-locale.test.ts +++ b/src/i18n/admin-settings-locale.test.ts @@ -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') diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1d14b11d..a9768dcd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a8044dc8..f905a852 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "任何人都可以注册", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b6fb5082..1429b8f9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/_authenticated/admin/index.tsx b/src/routes/_authenticated/admin/index.tsx index 9ac07d7f..a83621d5 100644 --- a/src/routes/_authenticated/admin/index.tsx +++ b/src/routes/_authenticated/admin/index.tsx @@ -104,9 +104,9 @@ function OverviewPage() { diff --git a/src/routes/_authenticated/admin/settings/auth.tsx b/src/routes/_authenticated/admin/settings/auth.tsx deleted file mode 100644 index 347bc0b2..00000000 --- a/src/routes/_authenticated/admin/settings/auth.tsx +++ /dev/null @@ -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 ( -
-

{t('admin.auth.title')}

-
- - - -
-
- ) -} diff --git a/src/routes/_authenticated/admin/settings/index.tsx b/src/routes/_authenticated/admin/settings/index.tsx index a67f7b13..f68d3bc9 100644 --- a/src/routes/_authenticated/admin/settings/index.tsx +++ b/src/routes/_authenticated/admin/settings/index.tsx @@ -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 @@ -41,18 +46,32 @@ type SettingsFormValues = z.infer 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({ 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 (
@@ -80,76 +105,147 @@ function SettingsPage() { return (
-
-

{t('admin.settings.title')}

- -
saveMutation.mutate(v))} className="max-w-2xl space-y-4"> -
-
-

{t('admin.settings.siteSection')}

- -
- - - {form.formState.errors.siteName && ( -

{form.formState.errors.siteName.message}

- )} + saveMutation.mutate(v))} className="space-y-6"> + + +
+
+
- -
- -