diff --git a/shared/feature-registry.ts b/shared/feature-registry.ts index 94823d57..3d9da7e4 100644 --- a/shared/feature-registry.ts +++ b/shared/feature-registry.ts @@ -128,6 +128,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + gateKey: 'audit_log', comingSoon: true, }, { diff --git a/src/components/UpgradeHint.tsx b/src/components/UpgradeHint.tsx index 49445db5..9469efaf 100644 --- a/src/components/UpgradeHint.tsx +++ b/src/components/UpgradeHint.tsx @@ -8,6 +8,7 @@ const FEATURE_LABELS: Record = { open_registration: 'open registration', teams_unlimited: 'unlimited teams', storages_unlimited: 'unlimited storages', + audit_log: 'audit logs', } interface UpgradeHintProps { diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 12c873b5..813ec916 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -1,5 +1,15 @@ import { Link } from '@tanstack/react-router' -import { ArrowLeft, BadgeCheck, Database, KeyRound, LayoutDashboard, Mail, Settings, Users } from 'lucide-react' +import { + ArrowLeft, + BadgeCheck, + Database, + KeyRound, + LayoutDashboard, + Mail, + Settings, + ShieldCheck, + Users, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { @@ -19,9 +29,10 @@ 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.settings', url: '/admin/settings', icon: Settings }, { 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.audit', url: '/admin/audit', icon: ShieldCheck }, { titleKey: 'admin.nav.licensing', url: '/admin/licensing', icon: BadgeCheck }, ] diff --git a/src/components/billing/BoundStatusCard.tsx b/src/components/billing/BoundStatusCard.tsx index 2dac9e71..31aee241 100644 --- a/src/components/billing/BoundStatusCard.tsx +++ b/src/components/billing/BoundStatusCard.tsx @@ -23,6 +23,7 @@ const FEATURE_LABELS: Record = { open_registration: 'Open registration', teams_unlimited: 'Unlimited teams', storages_unlimited: 'Unlimited storages', + audit_log: 'Audit logs', } function formatTimestamp(ts: number | undefined, fallback: string): string { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c6aee4a..9b65992e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -109,6 +109,7 @@ "admin.nav.management": "Management", "admin.nav.overview": "Overview", "admin.nav.storages": "Storages", + "admin.nav.audit": "Audit Logs", "admin.nav.users": "Users", "admin.nav.settings": "Settings", "admin.nav.email": "Email", @@ -158,6 +159,11 @@ "admin.users.inviteColExpiresAt": "Expires At", "admin.users.copyInviteLink": "Copy Invite Link", "admin.users.inviteLinkCopied": "Invite link copied to clipboard", + "admin.audit.title": "Audit Logs", + "admin.audit.description": "Track administrative and user activity across the instance.", + "admin.audit.proTooltip": "Audit Logs are available with ZPan Pro.", + "admin.audit.placeholderTitle": "Audit Logs Coming Soon", + "admin.audit.placeholderDescription": "This page is reserved for the future audit log experience.", "admin.users.resendInvite": "Resend Invite", "admin.users.revokeInvite": "Revoke Invite", "admin.users.inviteStatus.pending": "Pending", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 42c463f7..14748856 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -109,6 +109,7 @@ "admin.nav.management": "管理", "admin.nav.overview": "概览", "admin.nav.storages": "存储", + "admin.nav.audit": "审计日志", "admin.nav.users": "用户", "admin.nav.settings": "设置", "admin.nav.email": "邮件", @@ -158,6 +159,11 @@ "admin.users.inviteColExpiresAt": "过期时间", "admin.users.copyInviteLink": "复制邀请链接", "admin.users.inviteLinkCopied": "邀请链接已复制到剪贴板", + "admin.audit.title": "审计日志", + "admin.audit.description": "跟踪实例中的管理和用户操作。", + "admin.audit.proTooltip": "审计日志仅在 ZPan Pro 中可用。", + "admin.audit.placeholderTitle": "审计日志即将推出", + "admin.audit.placeholderDescription": "此页面已预留给未来的审计日志体验。", "admin.users.resendInvite": "重新发送邀请", "admin.users.revokeInvite": "撤销邀请", "admin.users.inviteStatus.pending": "待使用", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 1429b8f9..a61a1600 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_aut import { Route as AuthenticatedSettingsIhostRouteImport } from './routes/_authenticated/settings/ihost' import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance' import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing' +import { Route as AuthenticatedAdminAuditRouteImport } from './routes/_authenticated/admin/audit' import { Route as AuthenticatedTeamsTeamIdRouteRouteImport } from './routes/_authenticated/teams/$teamId/route' import { Route as AuthenticatedTeamsTeamIdIndexRouteImport } from './routes/_authenticated/teams/$teamId/index' import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index' @@ -174,6 +175,11 @@ const AuthenticatedAdminLicensingRoute = path: '/licensing', getParentRoute: () => AuthenticatedAdminRouteRoute, } as any) +const AuthenticatedAdminAuditRoute = AuthenticatedAdminAuditRouteImport.update({ + id: '/audit', + path: '/audit', + getParentRoute: () => AuthenticatedAdminRouteRoute, +} as any) const AuthenticatedTeamsTeamIdRouteRoute = AuthenticatedTeamsTeamIdRouteRouteImport.update({ id: '/teams/$teamId', @@ -245,6 +251,7 @@ export interface FileRoutesByFullPath { '/s/$token': typeof STokenRoute '/u/$username': typeof UUsernameRoute '/teams/$teamId': typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren + '/admin/audit': typeof AuthenticatedAdminAuditRoute '/admin/licensing': typeof AuthenticatedAdminLicensingRoute '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/settings/ihost': typeof AuthenticatedSettingsIhostRoute @@ -277,6 +284,7 @@ export interface FileRoutesByTo { '/s/$token': typeof STokenRoute '/u/$username': typeof UUsernameRoute '/': typeof AuthenticatedIndexRoute + '/admin/audit': typeof AuthenticatedAdminAuditRoute '/admin/licensing': typeof AuthenticatedAdminLicensingRoute '/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/settings/ihost': typeof AuthenticatedSettingsIhostRoute @@ -314,6 +322,7 @@ export interface FileRoutesById { '/u/$username': typeof UUsernameRoute '/_authenticated/': typeof AuthenticatedIndexRoute '/_authenticated/teams/$teamId': typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren + '/_authenticated/admin/audit': typeof AuthenticatedAdminAuditRoute '/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute '/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute '/_authenticated/settings/ihost': typeof AuthenticatedSettingsIhostRoute @@ -351,6 +360,7 @@ export interface FileRouteTypes { | '/s/$token' | '/u/$username' | '/teams/$teamId' + | '/admin/audit' | '/admin/licensing' | '/settings/appearance' | '/settings/ihost' @@ -383,6 +393,7 @@ export interface FileRouteTypes { | '/s/$token' | '/u/$username' | '/' + | '/admin/audit' | '/admin/licensing' | '/settings/appearance' | '/settings/ihost' @@ -419,6 +430,7 @@ export interface FileRouteTypes { | '/u/$username' | '/_authenticated/' | '/_authenticated/teams/$teamId' + | '/_authenticated/admin/audit' | '/_authenticated/admin/licensing' | '/_authenticated/settings/appearance' | '/_authenticated/settings/ihost' @@ -623,6 +635,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAdminLicensingRouteImport parentRoute: typeof AuthenticatedAdminRouteRoute } + '/_authenticated/admin/audit': { + id: '/_authenticated/admin/audit' + path: '/audit' + fullPath: '/admin/audit' + preLoaderRoute: typeof AuthenticatedAdminAuditRouteImport + parentRoute: typeof AuthenticatedAdminRouteRoute + } '/_authenticated/teams/$teamId': { id: '/_authenticated/teams/$teamId' path: '/teams/$teamId' @@ -697,6 +716,7 @@ declare module '@tanstack/react-router' { } interface AuthenticatedAdminRouteRouteChildren { + AuthenticatedAdminAuditRoute: typeof AuthenticatedAdminAuditRoute AuthenticatedAdminLicensingRoute: typeof AuthenticatedAdminLicensingRoute AuthenticatedAdminIndexRoute: typeof AuthenticatedAdminIndexRoute AuthenticatedAdminSettingsEmailRoute: typeof AuthenticatedAdminSettingsEmailRoute @@ -708,6 +728,7 @@ interface AuthenticatedAdminRouteRouteChildren { const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren = { + AuthenticatedAdminAuditRoute: AuthenticatedAdminAuditRoute, AuthenticatedAdminLicensingRoute: AuthenticatedAdminLicensingRoute, AuthenticatedAdminIndexRoute: AuthenticatedAdminIndexRoute, AuthenticatedAdminSettingsEmailRoute: AuthenticatedAdminSettingsEmailRoute, diff --git a/src/routes/_authenticated/admin/audit.tsx b/src/routes/_authenticated/admin/audit.tsx new file mode 100644 index 00000000..1470a35a --- /dev/null +++ b/src/routes/_authenticated/admin/audit.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ShieldCheck } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { ProBadge } from '@/components/ProBadge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +export const Route = createFileRoute('/_authenticated/admin/audit')({ + component: AuditLogsPage, +}) + +function AuditLogsPage() { + const { t } = useTranslation() + + return ( +
+
+

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

+ +
+ +

{t('admin.audit.description')}

+ + + +
+
+ +
+
+ {t('admin.audit.placeholderTitle')} + {t('admin.audit.placeholderDescription')} +
+
+
+ + Audit Logs UI placeholder only. No backend events are wired yet. + +
+
+ ) +} diff --git a/src/routes/_authenticated/admin/billing.test.tsx b/src/routes/_authenticated/admin/billing.test.tsx index ed611248..bfd41aa3 100644 --- a/src/routes/_authenticated/admin/billing.test.tsx +++ b/src/routes/_authenticated/admin/billing.test.tsx @@ -65,18 +65,25 @@ describe('resolveBillingView', () => { // Pro features — label mapping in BoundStatusCard // --------------------------------------------------------------------------- -type ProFeature = 'white_label' | 'open_registration' | 'teams_unlimited' | 'storages_unlimited' +type ProFeature = 'white_label' | 'open_registration' | 'teams_unlimited' | 'storages_unlimited' | 'audit_log' const FEATURE_LABELS: Record = { white_label: 'White-label branding', open_registration: 'Open registration', teams_unlimited: 'Unlimited teams', storages_unlimited: 'Unlimited storages', + audit_log: 'Audit logs', } describe('FEATURE_LABELS', () => { it('maps all known ProFeature keys', () => { - const features: ProFeature[] = ['white_label', 'open_registration', 'teams_unlimited', 'storages_unlimited'] + const features: ProFeature[] = [ + 'white_label', + 'open_registration', + 'teams_unlimited', + 'storages_unlimited', + 'audit_log', + ] for (const f of features) { expect(FEATURE_LABELS[f]).toBeTruthy() } @@ -89,6 +96,10 @@ describe('FEATURE_LABELS', () => { it('storages_unlimited maps to readable label', () => { expect(FEATURE_LABELS.storages_unlimited).toBe('Unlimited storages') }) + + it('audit_log maps to readable label', () => { + expect(FEATURE_LABELS.audit_log).toBe('Audit logs') + }) }) // --------------------------------------------------------------------------- @@ -142,6 +153,7 @@ const COMPARISON_ROWS: FeatureRow[] = [ { label: 'Unlimited teams', community: false, pro: true }, { label: 'Open registration', community: false, pro: true }, { label: 'Unlimited storages', community: false, pro: true }, + { label: 'Audit logs', community: false, pro: true }, { label: 'White-label branding', community: false, pro: true }, ] @@ -161,11 +173,12 @@ describe('ComparisonTable rows', () => { expect(communityOnlyRows).toHaveLength(0) }) - it('all 4 Pro-exclusive features are represented', () => { + it('all 5 Pro-exclusive features are represented', () => { const proOnlyLabels = COMPARISON_ROWS.filter((r) => !r.community).map((r) => r.label) expect(proOnlyLabels).toContain('Unlimited teams') expect(proOnlyLabels).toContain('Open registration') expect(proOnlyLabels).toContain('Unlimited storages') + expect(proOnlyLabels).toContain('Audit logs') expect(proOnlyLabels).toContain('White-label branding') }) })