From 73a592c8d9a0931c8d72f64b3f86fed11752196d Mon Sep 17 00:00:00 2001 From: saltbo Date: Sun, 10 May 2026 01:58:49 -0400 Subject: [PATCH] feat(pro): tighten gates and unify upgrade prompts --- scripts/db-reset.ts | 2 +- .../routes/announcements.integration.test.ts | 51 ++++-- server/routes/announcements.ts | 3 + .../licensing-admin.integration.test.ts | 9 +- server/routes/licensing-admin.ts | 10 +- shared/feature-registry.ts | 7 + src/components/ProUpgradePrompt.test.tsx | 29 ++++ src/components/ProUpgradePrompt.tsx | 37 ++++ src/components/UpgradeHint.test.tsx | 62 ++----- src/components/UpgradeHint.tsx | 37 ++-- src/components/admin/admin-page-header.tsx | 23 +++ .../announcements/site-announcements.tsx | 4 + .../notifications/notification-dropdown.tsx | 23 ++- src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + .../_authenticated/admin/announcement.tsx | 162 ++++++++++-------- src/routes/_authenticated/admin/audit.tsx | 45 ++--- .../_authenticated/admin/cloud-store.tsx | 30 ++-- 18 files changed, 320 insertions(+), 216 deletions(-) create mode 100644 src/components/ProUpgradePrompt.test.tsx create mode 100644 src/components/ProUpgradePrompt.tsx create mode 100644 src/components/admin/admin-page-header.tsx diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts index 63e7310b..3989f095 100644 --- a/scripts/db-reset.ts +++ b/scripts/db-reset.ts @@ -17,7 +17,7 @@ const D1_STATE_DIR = '.wrangler/state/v3/d1' const D1_DB_NAME = 'zpan-db-staging' // ── required env vars ── -const email = 'admin@zpan.dev' +const email = 'admin@zpan.space' const password = requireEnv('DEV_ADMIN_PASSWORD') const name = 'Admin' diff --git a/server/routes/announcements.integration.test.ts b/server/routes/announcements.integration.test.ts index 32e2b187..1930d607 100644 --- a/server/routes/announcements.integration.test.ts +++ b/server/routes/announcements.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' +import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' const publishedAnnouncement = { title: 'Maintenance window', @@ -8,9 +8,12 @@ const publishedAnnouncement = { priority: 10, } -async function createPublishedAnnouncement(app: Awaited>['app']) { - const headers = await adminHeaders(app) - const res = await app.request('/api/admin/announcements', { +type TestContext = Awaited> + +async function createPublishedAnnouncement(ctx: TestContext) { + const headers = await adminHeaders(ctx.app) + await seedProLicense(ctx.db) + const res = await ctx.app.request('/api/admin/announcements', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(publishedAnnouncement), @@ -34,10 +37,21 @@ describe('Admin Announcements API', () => { expect(res.status).toBe(403) }) - it('creates, lists, updates, and deletes an announcement', async () => { + it('returns 402 when site announcements are not available', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) + const res = await app.request('/api/admin/announcements', { headers }) + expect(res.status).toBe(402) + const body = (await res.json()) as { feature: string } + expect(body.feature).toBe('site_announcements') + }) + + it('creates, lists, updates, and deletes an announcement', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await seedProLicense(db) + const createRes = await app.request('/api/admin/announcements', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, @@ -75,9 +89,21 @@ describe('User Announcements API', () => { expect(res.status).toBe(401) }) - it('returns active announcements', async () => { + it('returns 402 when site announcements are not available', async () => { const { app } = await createTestApp() - const created = await createPublishedAnnouncement(app) + await adminHeaders(app) + const headers = await authedHeaders(app, 'reader@example.com') + + const res = await app.request('/api/announcements', { headers }) + expect(res.status).toBe(402) + const body = (await res.json()) as { feature: string } + expect(body.feature).toBe('site_announcements') + }) + + it('returns active announcements', async () => { + const ctx = await createTestApp() + const { app } = ctx + const created = await createPublishedAnnouncement(ctx) const headers = await authedHeaders(app, 'reader@example.com') const activeRes = await app.request('/api/announcements?scope=active', { headers }) @@ -88,8 +114,9 @@ describe('User Announcements API', () => { }) it('keeps archived announcements in history but not active list', async () => { - const { app } = await createTestApp() + const { app, db } = await createTestApp() const admin = await adminHeaders(app) + await seedProLicense(db) const createRes = await app.request('/api/admin/announcements', { method: 'POST', headers: { ...admin, 'Content-Type': 'application/json' }, @@ -116,8 +143,9 @@ describe('User Announcements API', () => { }) it('does not include draft announcements in history', async () => { - const { app } = await createTestApp() + const { app, db } = await createTestApp() const admin = await adminHeaders(app) + await seedProLicense(db) await app.request('/api/admin/announcements', { method: 'POST', headers: { ...admin, 'Content-Type': 'application/json' }, @@ -132,8 +160,9 @@ describe('User Announcements API', () => { }) it('rejects invalid pagination query values', async () => { - const { app } = await createTestApp() - await createPublishedAnnouncement(app) + const ctx = await createTestApp() + const { app } = ctx + await createPublishedAnnouncement(ctx) const headers = await authedHeaders(app, 'reader@example.com') const res = await app.request('/api/announcements?page=abc&pageSize=xyz', { headers }) diff --git a/server/routes/announcements.ts b/server/routes/announcements.ts index 58b57c5b..897e6515 100644 --- a/server/routes/announcements.ts +++ b/server/routes/announcements.ts @@ -7,6 +7,7 @@ import { } from '../../shared/schemas' import { requireAdmin, requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' +import { requireFeature } from '../middleware/require-feature' import { createAnnouncement, deleteAnnouncement, @@ -25,6 +26,7 @@ function pagination(query: { page?: string; pageSize?: string }) { export const announcements = new Hono() .use(requireAuth) + .use(requireFeature('site_announcements')) .get('/', zValidator('query', listAnnouncementsQuerySchema), async (c) => { const db = c.get('platform').db const query = c.req.valid('query') @@ -37,6 +39,7 @@ export const announcements = new Hono() export const adminAnnouncements = new Hono() .use(requireAdmin) + .use(requireFeature('site_announcements')) .get('/', zValidator('query', listAdminAnnouncementsQuerySchema), async (c) => { const db = c.get('platform').db const query = c.req.valid('query') diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index 2726b886..b65ba661 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -356,7 +356,7 @@ describe('DELETE /api/licensing/binding', () => { expect(state.refreshToken).toBeNull() }) - it('keeps the local binding when Cloud unbind fails', async () => { + it('clears the local binding when Cloud unbind fails', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -365,10 +365,13 @@ describe('DELETE /api/licensing/binding', () => { const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers }) - expect(res.status).toBe(500) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.deleted).toBe(true) + expect(body.cloud_unbind_error).toContain('Cloud unbind failed') const state = await loadLicenseState(db) - expect(state.refreshToken).toBe('old-token') + expect(state.refreshToken).toBeNull() }) it('returns deleted: true even when no binding exists', async () => { diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index 5a5e0d1a..aa775e28 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -167,9 +167,14 @@ const app = new Hono() const orgId = c.get('orgId')! const baseUrl = getCloudBaseUrl(c) const state = await loadLicenseState(db) + let cloudUnbindError: string | null = null if (state.refreshToken) { - await unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken) + try { + await unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken) + } catch (error) { + cloudUnbindError = error instanceof Error ? error.message : 'Cloud unbind failed' + } } await clearLicenseBinding(db) @@ -181,9 +186,10 @@ const app = new Hono() action: 'license_disconnect', targetType: 'license', targetName: 'license binding', + metadata: cloudUnbindError ? { cloudUnbindError } : undefined, }) - return c.json({ deleted: true }) + return c.json({ deleted: true, cloud_unbind_error: cloudUnbindError }) }) export default app diff --git a/shared/feature-registry.ts b/shared/feature-registry.ts index 966028d2..e11d53c8 100644 --- a/shared/feature-registry.ts +++ b/shared/feature-registry.ts @@ -116,6 +116,13 @@ export const FEATURE_REGISTRY = [ pro: true, gateKey: 'quota_store', }, + { + i18nKey: 'features.siteAnnouncements', + category: 'pro', + community: false, + pro: true, + gateKey: 'site_announcements', + }, { i18nKey: 'features.multiIdpSso', category: 'pro', diff --git a/src/components/ProUpgradePrompt.test.tsx b/src/components/ProUpgradePrompt.test.tsx new file mode 100644 index 00000000..2f2f9749 --- /dev/null +++ b/src/components/ProUpgradePrompt.test.tsx @@ -0,0 +1,29 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { ProUpgradePrompt } from './ProUpgradePrompt' + +afterEach(cleanup) + +describe('ProUpgradePrompt', () => { + it('renders title, description, and action', () => { + const { getByText } = render( + , + ) + + expect(getByText('Unlock Audit Logs')).toBeTruthy() + expect(getByText('Audit Logs are a Pro feature.')).toBeTruthy() + expect(getByText('Upgrade')).toBeTruthy() + }) + + it('links to licensing by default', () => { + const { getByRole } = render() + + expect(getByRole('link').getAttribute('href')).toBe('/admin/licensing') + }) + + it('renders with the pro-upgrade-prompt slot attribute', () => { + const { container } = render() + + expect(container.querySelector('[data-slot="pro-upgrade-prompt"]')).toBeTruthy() + }) +}) diff --git a/src/components/ProUpgradePrompt.tsx b/src/components/ProUpgradePrompt.tsx new file mode 100644 index 00000000..b72e5124 --- /dev/null +++ b/src/components/ProUpgradePrompt.tsx @@ -0,0 +1,37 @@ +import { type LucideIcon, ShieldCheck } from 'lucide-react' +import type { ReactNode } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' + +interface ProUpgradePromptProps { + title: ReactNode + description: ReactNode + actionLabel: ReactNode + icon?: LucideIcon + href?: string +} + +export function ProUpgradePrompt({ + title, + description, + actionLabel, + icon: Icon = ShieldCheck, + href = '/admin/licensing', +}: ProUpgradePromptProps) { + return ( + +
+
+ +
+
+

{title}

+

{description}

+
+ +
+
+ ) +} diff --git a/src/components/UpgradeHint.test.tsx b/src/components/UpgradeHint.test.tsx index 774277a5..e9ac9a83 100644 --- a/src/components/UpgradeHint.test.tsx +++ b/src/components/UpgradeHint.test.tsx @@ -1,17 +1,13 @@ // Tests for src/components/UpgradeHint.tsx import { cleanup, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { UpgradeHint } from './UpgradeHint' // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -vi.mock('@/hooks/useEntitlement', () => ({ - useEntitlement: vi.fn(), -})) - -vi.mock('./ui/button', () => ({ +vi.mock('@/components/ui/button', () => ({ Button: ({ children, asChild, @@ -30,80 +26,56 @@ vi.mock('./ui/button', () => ({ ), })) -vi.mock('./ui/card', () => ({ +vi.mock('@/components/ui/card', () => ({ Card: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
{children}
), - CardHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, - CardTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, - CardContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - CardFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -import { useEntitlement } from '@/hooks/useEntitlement' - -function setupEntitlement(bound: boolean) { - vi.mocked(useEntitlement).mockReturnValue({ - bound, - active: false, - edition: bound ? 'pro' : null, - hasFeature: () => false, - isLoading: false, - isError: false, - }) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- afterEach(cleanup) -describe('UpgradeHint — unbound state', () => { - beforeEach(() => setupEntitlement(false)) - +describe('UpgradeHint', () => { it('renders headline "Unlock with ZPan Pro"', () => { const { getByText } = render() expect(getByText('Unlock with ZPan Pro')).toBeTruthy() }) - it('renders CTA "Connect to Cloud" when not bound', () => { + it('renders CTA "Upgrade to Pro"', () => { const { getByText } = render() - expect(getByText('Connect to Cloud')).toBeTruthy() + expect(getByText('Upgrade to Pro')).toBeTruthy() }) - it('links CTA to /admin/billing', () => { + it('links CTA to /admin/licensing', () => { const { getByRole } = render() const link = getByRole('link') - expect(link.getAttribute('href')).toBe('/admin/billing') + expect(link.getAttribute('href')).toBe('/admin/licensing') }) it('mentions the feature in the description', () => { const { getByText } = render() expect(getByText(/white-label/i)).toBeTruthy() }) -}) -describe('UpgradeHint — bound state', () => { - beforeEach(() => setupEntitlement(true)) - - it('renders CTA "Manage on Cloud" when bound', () => { - const { getByText } = render() - expect(getByText('Manage on Cloud')).toBeTruthy() - }) - - it('does not render "Connect to Cloud" when bound', () => { - const { queryByText } = render() - expect(queryByText('Connect to Cloud')).toBeNull() + it('uses a custom action label when provided', () => { + const { getByText } = render() + expect(getByText('Open billing')).toBeTruthy() }) }) describe('UpgradeHint — data-slot', () => { - beforeEach(() => setupEntitlement(false)) - it('renders with upgrade-hint slot attribute', () => { const { container } = render() const el = container.querySelector('[data-slot="upgrade-hint"]') expect(el).toBeTruthy() }) + + it('renders the shared pro-upgrade-prompt slot', () => { + const { container } = render() + const el = container.querySelector('[data-slot="pro-upgrade-prompt"]') + expect(el).toBeTruthy() + }) }) diff --git a/src/components/UpgradeHint.tsx b/src/components/UpgradeHint.tsx index de29cace..8839ec0b 100644 --- a/src/components/UpgradeHint.tsx +++ b/src/components/UpgradeHint.tsx @@ -1,7 +1,5 @@ import type { ProFeature } from '@shared/types' -import { useEntitlement } from '@/hooks/useEntitlement' -import { Button } from './ui/button' -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card' +import { ProUpgradePrompt } from './ProUpgradePrompt' const FEATURE_LABELS: Record = { white_label: 'white-label branding', @@ -10,32 +8,27 @@ const FEATURE_LABELS: Record = { storages_unlimited: 'unlimited storages', audit_log: 'audit logs', quota_store: 'storage quota store', + site_announcements: 'site announcements', } -interface UpgradeHintProps { +export interface UpgradeHintProps { feature: ProFeature + title?: string + description?: string + actionLabel?: string } -export function UpgradeHint({ feature }: UpgradeHintProps) { - const { bound } = useEntitlement() +export function UpgradeHint({ feature, title, description, actionLabel }: UpgradeHintProps) { const featureLabel = FEATURE_LABELS[feature] ?? feature + const displayName = featureLabel.charAt(0).toUpperCase() + featureLabel.slice(1) return ( - - - Unlock with ZPan Pro - - -

- {featureLabel.charAt(0).toUpperCase() + featureLabel.slice(1)} is a Pro feature. Upgrade your plan to access - it. -

-
- - - -
+
+ +
) } diff --git a/src/components/admin/admin-page-header.tsx b/src/components/admin/admin-page-header.tsx new file mode 100644 index 00000000..bb347c36 --- /dev/null +++ b/src/components/admin/admin-page-header.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +interface AdminPageHeaderProps { + title: ReactNode + description?: ReactNode + badge?: ReactNode + action?: ReactNode +} + +export function AdminPageHeader({ title, description, badge, action }: AdminPageHeaderProps) { + return ( +
+
+
+

{title}

+ {badge} +
+ {description &&

{description}

} +
+ {action} +
+ ) +} diff --git a/src/components/announcements/site-announcements.tsx b/src/components/announcements/site-announcements.tsx index 78aeea9d..84205810 100644 --- a/src/components/announcements/site-announcements.tsx +++ b/src/components/announcements/site-announcements.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useEntitlement } from '@/hooks/useEntitlement' import { listActiveAnnouncements } from '@/lib/api' import { cn } from '@/lib/utils' import { AnnouncementMarkdown } from './markdown-content' @@ -23,10 +24,13 @@ export function SiteAnnouncements() { const [open, setOpen] = useState(false) const [expandedId, setExpandedId] = useState(null) const [autoOpenKey, setAutoOpenKey] = useState(null) + const { hasFeature } = useEntitlement() + const announcementsEnabled = hasFeature('site_announcements') const { data } = useQuery({ queryKey: activeAnnouncementsQueryKey, queryFn: listActiveAnnouncements, + enabled: announcementsEnabled, }) const announcements = data?.items ?? [] diff --git a/src/components/notifications/notification-dropdown.tsx b/src/components/notifications/notification-dropdown.tsx index a28b24f4..b59abf09 100644 --- a/src/components/notifications/notification-dropdown.tsx +++ b/src/components/notifications/notification-dropdown.tsx @@ -3,12 +3,15 @@ import { useTranslation } from 'react-i18next' import { openAnnouncementsDialog } from '@/components/announcements/site-announcements' import { Button } from '@/components/ui/button' import { DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { useEntitlement } from '@/hooks/useEntitlement' import { listNotifications, markAllNotificationsRead } from '@/lib/api' import { NotificationItem } from './notification-item' export function NotificationDropdown() { const { t } = useTranslation() const queryClient = useQueryClient() + const { hasFeature } = useEntitlement() + const announcementsEnabled = hasFeature('site_announcements') const { data } = useQuery({ queryKey: ['notifications', 'list'], @@ -45,14 +48,18 @@ export function NotificationDropdown() { items.map((item) => ) )} - - + {announcementsEnabled && ( + <> + + + + )} ) } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4ea85047..1c81f5d2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -992,6 +992,7 @@ "features.whiteLabel": "Custom Branding", "features.auditLog": "Audit Logs", "features.cloudStore": "Storage Plans", + "features.siteAnnouncements": "Site Announcements", "features.webhooks": "Event Webhooks", "features.analytics": "Analytics", "settings.billing.pairing.title": "Connect to ZPan Cloud", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1da5ac1f..a2218bc2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -992,6 +992,7 @@ "features.whiteLabel": "自定义品牌", "features.auditLog": "审计日志", "features.cloudStore": "存储套餐", + "features.siteAnnouncements": "站点公告", "features.webhooks": "事件 Webhooks", "features.analytics": "统计分析", "settings.billing.pairing.title": "连接 ZPan Cloud", diff --git a/src/routes/_authenticated/admin/announcement.tsx b/src/routes/_authenticated/admin/announcement.tsx index d9826710..ace95226 100644 --- a/src/routes/_authenticated/admin/announcement.tsx +++ b/src/routes/_authenticated/admin/announcement.tsx @@ -4,10 +4,14 @@ import { Archive, Megaphone, Pencil, Pin, Plus, Send, Trash2 } from 'lucide-reac import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { AdminPageHeader } from '@/components/admin/admin-page-header' import { AnnouncementFormDialog } from '@/components/admin/announcement-form-dialog' +import { ProBadge } from '@/components/ProBadge' +import { UpgradeHint } from '@/components/UpgradeHint' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useEntitlement } from '@/hooks/useEntitlement' import type { Announcement, AnnouncementInput } from '@/lib/api' import { createAnnouncement, deleteAnnouncement, listAdminAnnouncements, updateAnnouncement } from '@/lib/api' @@ -32,10 +36,13 @@ function AnnouncementPage() { const [status, setStatus] = useState('all') const [formOpen, setFormOpen] = useState(false) const [editing, setEditing] = useState(null) + const { hasFeature, isLoading: entitlementLoading } = useEntitlement() + const announcementsEnabled = hasFeature('site_announcements') const announcementsQuery = useQuery({ queryKey: ['admin', 'announcements', status], queryFn: () => listAdminAnnouncements(1, 50, status === 'all' ? undefined : status), + enabled: announcementsEnabled, }) function invalidateAnnouncements() { @@ -95,81 +102,88 @@ function AnnouncementPage() { return (
-
-
-

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

-

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

-
- -
- -
- -
- -
- - - - - - - - - - - {announcements.map((announcement) => ( - { - setEditing(announcement) - setFormOpen(true) - }} - onPublish={() => handleStatusChange(announcement, 'published')} - onArchive={() => handleStatusChange(announcement, 'archived')} - onDelete={() => handleDelete(announcement)} - /> - ))} - {announcements.length === 0 && ( - - - - )} - -
{t('admin.announcement.fieldTitle')}{t('admin.announcement.fieldStatus')} - {t('admin.announcement.fieldPublishedAt')} - {t('admin.storages.colActions')}
-
- -

{t('admin.announcement.empty')}

-
-
-
- - { - setFormOpen(open) - if (!open) setEditing(null) - }} - onSubmit={handleSave} + } + action={ + + } /> + + {!entitlementLoading && !announcementsEnabled && } + + {announcementsEnabled && ( + <> +
+ +
+ +
+ + + + + + + + + + + {announcements.map((announcement) => ( + { + setEditing(announcement) + setFormOpen(true) + }} + onPublish={() => handleStatusChange(announcement, 'published')} + onArchive={() => handleStatusChange(announcement, 'archived')} + onDelete={() => handleDelete(announcement)} + /> + ))} + {announcements.length === 0 && ( + + + + )} + +
{t('admin.announcement.fieldTitle')}{t('admin.announcement.fieldStatus')} + {t('admin.announcement.fieldPublishedAt')} + {t('admin.storages.colActions')}
+
+ +

{t('admin.announcement.empty')}

+
+
+
+ + { + setFormOpen(open) + if (!open) setEditing(null) + }} + onSubmit={handleSave} + /> + + )}
) } diff --git a/src/routes/_authenticated/admin/audit.tsx b/src/routes/_authenticated/admin/audit.tsx index cbc9e73f..bab04de8 100644 --- a/src/routes/_authenticated/admin/audit.tsx +++ b/src/routes/_authenticated/admin/audit.tsx @@ -1,9 +1,10 @@ import type { AdminAuditEvent } from '@shared/types' import { useInfiniteQuery } from '@tanstack/react-query' -import { createFileRoute, Link } from '@tanstack/react-router' -import { ShieldCheck } from 'lucide-react' +import { createFileRoute } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { AdminPageHeader } from '@/components/admin/admin-page-header' import { ProBadge } from '@/components/ProBadge' +import { UpgradeHint } from '@/components/UpgradeHint' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' @@ -80,26 +81,6 @@ function AuditRow({ event }: { event: AdminAuditEvent }) { ) } -function UpgradePrompt() { - const { t } = useTranslation() - return ( - -
-
- -
-
-

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

-

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

-
- -
-
- ) -} - const PAGE_SIZE = 20 function AuditLogsPage() { @@ -121,16 +102,20 @@ function AuditLogsPage() { const allItems = data?.pages.flatMap((p) => p.items) ?? [] return ( -
-
-

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

- {!entitlementLoading && !auditEnabled && } -
- -

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

+
+ } + /> {entitlementLoading ? null : !auditEnabled ? ( - + ) : isPending ? (
{[1, 2, 3, 4, 5].map((i) => ( diff --git a/src/routes/_authenticated/admin/cloud-store.tsx b/src/routes/_authenticated/admin/cloud-store.tsx index 46b18e21..3394b71d 100644 --- a/src/routes/_authenticated/admin/cloud-store.tsx +++ b/src/routes/_authenticated/admin/cloud-store.tsx @@ -5,6 +5,7 @@ import { createFileRoute } from '@tanstack/react-router' import { CheckCircle2, CircleSlash2, XCircle } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { AdminPageHeader } from '@/components/admin/admin-page-header' import { StorageOrdersTable } from '@/components/admin/cloud-orders-table' import { GiftCardsTab, @@ -19,7 +20,6 @@ import { } from '@/components/admin/cloud-store-admin-shell' import { ProBadge } from '@/components/ProBadge' import { UpgradeHint } from '@/components/UpgradeHint' -import { Card, CardContent } from '@/components/ui/card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ApiError, @@ -85,7 +85,7 @@ type AdminCloudStoreReadyState = ReturnType & { function AdminCloudStoreContent({ state }: { state: AdminCloudStoreReadyState }) { const { data } = state return ( -
+
@@ -96,28 +96,18 @@ function AdminCloudStoreContent({ state }: { state: AdminCloudStoreReadyState }) function PageHeading({ settings }: { settings: CloudStoreSettings | null }) { const { t } = useTranslation() return ( -
-
-
-

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

- -
-

{t('admin.cloudStore.subtitle')}

-
- -
+ } + action={} + /> ) } function UpgradeGate({ available }: { available: boolean }) { if (available) return null - return ( - - - - - - ) + return } function CloudStoreStatusSummary({ settings }: { settings: CloudStoreSettings | null }) { @@ -125,7 +115,7 @@ function CloudStoreStatusSummary({ settings }: { settings: CloudStoreSettings | const open = settings?.enabled ?? false const connected = settings?.status === 'ready' return ( -
+