feat(pro): tighten gates and unify upgrade prompts

This commit is contained in:
saltbo
2026-05-10 01:58:49 -04:00
parent ddeb49ded6
commit 73a592c8d9
18 changed files with 320 additions and 216 deletions
+1 -1
View File
@@ -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'
+40 -11
View File
@@ -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<ReturnType<typeof createTestApp>>['app']) {
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/announcements', {
type TestContext = Awaited<ReturnType<typeof createTestApp>>
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 })
+3
View File
@@ -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<Env>()
.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<Env>()
export const adminAnnouncements = new Hono<Env>()
.use(requireAdmin)
.use(requireFeature('site_announcements'))
.get('/', zValidator('query', listAdminAnnouncementsQuerySchema), async (c) => {
const db = c.get('platform').db
const query = c.req.valid('query')
@@ -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<string, unknown>
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 () => {
+8 -2
View File
@@ -167,9 +167,14 @@ const app = new Hono<Env>()
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<Env>()
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
+7
View File
@@ -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',
+29
View File
@@ -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(
<ProUpgradePrompt title="Unlock Audit Logs" description="Audit Logs are a Pro feature." actionLabel="Upgrade" />,
)
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(<ProUpgradePrompt title="Unlock" description="Description" actionLabel="Upgrade" />)
expect(getByRole('link').getAttribute('href')).toBe('/admin/licensing')
})
it('renders with the pro-upgrade-prompt slot attribute', () => {
const { container } = render(<ProUpgradePrompt title="Unlock" description="Description" actionLabel="Upgrade" />)
expect(container.querySelector('[data-slot="pro-upgrade-prompt"]')).toBeTruthy()
})
})
+37
View File
@@ -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 (
<Card data-slot="pro-upgrade-prompt" className="border-dashed">
<div className="flex flex-col items-center gap-4 p-8 text-center">
<div className="rounded-2xl border border-border/60 bg-primary/10 p-3 text-primary">
<Icon className="h-6 w-6" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Button asChild style={{ backgroundColor: '#1A73E8' }}>
<a href={href}>{actionLabel}</a>
</Button>
</div>
</Card>
)
}
+17 -45
View File
@@ -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 }) => (
<div {...props}>{children}</div>
),
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CardFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
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(<UpgradeHint feature="white_label" />)
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(<UpgradeHint feature="teams_unlimited" />)
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(<UpgradeHint feature="white_label" />)
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(<UpgradeHint feature="white_label" />)
expect(getByText(/white-label/i)).toBeTruthy()
})
})
describe('UpgradeHint — bound state', () => {
beforeEach(() => setupEntitlement(true))
it('renders CTA "Manage on Cloud" when bound', () => {
const { getByText } = render(<UpgradeHint feature="storages_unlimited" />)
expect(getByText('Manage on Cloud')).toBeTruthy()
})
it('does not render "Connect to Cloud" when bound', () => {
const { queryByText } = render(<UpgradeHint feature="storages_unlimited" />)
expect(queryByText('Connect to Cloud')).toBeNull()
it('uses a custom action label when provided', () => {
const { getByText } = render(<UpgradeHint feature="audit_log" actionLabel="Open billing" />)
expect(getByText('Open billing')).toBeTruthy()
})
})
describe('UpgradeHint — data-slot', () => {
beforeEach(() => setupEntitlement(false))
it('renders with upgrade-hint slot attribute', () => {
const { container } = render(<UpgradeHint feature="open_registration" />)
const el = container.querySelector('[data-slot="upgrade-hint"]')
expect(el).toBeTruthy()
})
it('renders the shared pro-upgrade-prompt slot', () => {
const { container } = render(<UpgradeHint feature="open_registration" />)
const el = container.querySelector('[data-slot="pro-upgrade-prompt"]')
expect(el).toBeTruthy()
})
})
+15 -22
View File
@@ -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<ProFeature, string> = {
white_label: 'white-label branding',
@@ -10,32 +8,27 @@ const FEATURE_LABELS: Record<ProFeature, string> = {
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 (
<Card data-slot="upgrade-hint">
<CardHeader>
<CardTitle>Unlock with ZPan Pro</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{featureLabel.charAt(0).toUpperCase() + featureLabel.slice(1)} is a Pro feature. Upgrade your plan to access
it.
</p>
</CardContent>
<CardFooter>
<Button asChild>
<a href="/admin/billing">{bound ? 'Manage on Cloud' : 'Connect to Cloud'}</a>
</Button>
</CardFooter>
</Card>
<div data-slot="upgrade-hint">
<ProUpgradePrompt
title={title ?? 'Unlock with ZPan Pro'}
description={description ?? `${displayName} is a Pro feature. Upgrade your plan to access it.`}
actionLabel={actionLabel ?? 'Upgrade to Pro'}
/>
</div>
)
}
@@ -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 (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
{badge}
</div>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)
}
@@ -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<string | null>(null)
const [autoOpenKey, setAutoOpenKey] = useState<string | null>(null)
const { hasFeature } = useEntitlement()
const announcementsEnabled = hasFeature('site_announcements')
const { data } = useQuery({
queryKey: activeAnnouncementsQueryKey,
queryFn: listActiveAnnouncements,
enabled: announcementsEnabled,
})
const announcements = data?.items ?? []
@@ -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) => <NotificationItem key={item.id} notification={item} onRead={handleItemRead} />)
)}
</div>
<DropdownMenuSeparator className="m-0" />
<Button
variant="ghost"
className="h-10 w-full justify-start rounded-none px-4 text-sm"
onClick={openAnnouncementsDialog}
>
{t('announcement.title')}
</Button>
{announcementsEnabled && (
<>
<DropdownMenuSeparator className="m-0" />
<Button
variant="ghost"
className="h-10 w-full justify-start rounded-none px-4 text-sm"
onClick={openAnnouncementsDialog}
>
{t('announcement.title')}
</Button>
</>
)}
</DropdownMenuContent>
)
}
+1
View File
@@ -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",
+1
View File
@@ -992,6 +992,7 @@
"features.whiteLabel": "自定义品牌",
"features.auditLog": "审计日志",
"features.cloudStore": "存储套餐",
"features.siteAnnouncements": "站点公告",
"features.webhooks": "事件 Webhooks",
"features.analytics": "统计分析",
"settings.billing.pairing.title": "连接 ZPan Cloud",
@@ -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<StatusFilter>('all')
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<Announcement | null>(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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold">{t('admin.announcement.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('admin.announcement.description')}</p>
</div>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('admin.announcement.create')}
</Button>
</div>
<div className="w-48">
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.announcement.filterAll')}</SelectItem>
<SelectItem value="draft">{t('announcement.status.draft')}</SelectItem>
<SelectItem value="published">{t('announcement.status.published')}</SelectItem>
<SelectItem value="archived">{t('announcement.status.archived')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t('admin.announcement.fieldTitle')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.announcement.fieldStatus')}</th>
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">
{t('admin.announcement.fieldPublishedAt')}
</th>
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
</tr>
</thead>
<tbody>
{announcements.map((announcement) => (
<AnnouncementRow
key={announcement.id}
announcement={announcement}
onEdit={() => {
setEditing(announcement)
setFormOpen(true)
}}
onPublish={() => handleStatusChange(announcement, 'published')}
onArchive={() => handleStatusChange(announcement, 'archived')}
onDelete={() => handleDelete(announcement)}
/>
))}
{announcements.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Megaphone className="h-10 w-10" />
<p>{t('admin.announcement.empty')}</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<AnnouncementFormDialog
open={formOpen}
announcement={editing}
saving={createMutation.isPending || updateMutation.isPending}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditing(null)
}}
onSubmit={handleSave}
<AdminPageHeader
title={t('admin.announcement.title')}
description={t('admin.announcement.description')}
badge={!entitlementLoading && !announcementsEnabled && <ProBadge />}
action={
<Button size="sm" onClick={() => setFormOpen(true)} disabled={!announcementsEnabled}>
<Plus className="mr-2 h-4 w-4" />
{t('admin.announcement.create')}
</Button>
}
/>
{!entitlementLoading && !announcementsEnabled && <UpgradeHint feature="site_announcements" />}
{announcementsEnabled && (
<>
<div className="w-48">
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.announcement.filterAll')}</SelectItem>
<SelectItem value="draft">{t('announcement.status.draft')}</SelectItem>
<SelectItem value="published">{t('announcement.status.published')}</SelectItem>
<SelectItem value="archived">{t('announcement.status.archived')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t('admin.announcement.fieldTitle')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.announcement.fieldStatus')}</th>
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">
{t('admin.announcement.fieldPublishedAt')}
</th>
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
</tr>
</thead>
<tbody>
{announcements.map((announcement) => (
<AnnouncementRow
key={announcement.id}
announcement={announcement}
onEdit={() => {
setEditing(announcement)
setFormOpen(true)
}}
onPublish={() => handleStatusChange(announcement, 'published')}
onArchive={() => handleStatusChange(announcement, 'archived')}
onDelete={() => handleDelete(announcement)}
/>
))}
{announcements.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Megaphone className="h-10 w-10" />
<p>{t('admin.announcement.empty')}</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<AnnouncementFormDialog
open={formOpen}
announcement={editing}
saving={createMutation.isPending || updateMutation.isPending}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditing(null)
}}
onSubmit={handleSave}
/>
</>
)}
</div>
)
}
+15 -30
View File
@@ -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 (
<Card className="border-dashed">
<div className="flex flex-col items-center gap-4 p-8 text-center">
<div className="rounded-2xl border border-border/60 bg-primary/10 p-3 text-primary">
<ShieldCheck className="h-6 w-6" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">{t('admin.audit.upgradeTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.audit.upgradeDescription')}</p>
</div>
<Button asChild style={{ backgroundColor: '#1A73E8' }}>
<Link to="/admin/licensing">{t('admin.audit.upgradeButton')}</Link>
</Button>
</div>
</Card>
)
}
const PAGE_SIZE = 20
function AuditLogsPage() {
@@ -121,16 +102,20 @@ function AuditLogsPage() {
const allItems = data?.pages.flatMap((p) => p.items) ?? []
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{t('admin.audit.title')}</h2>
{!entitlementLoading && !auditEnabled && <ProBadge tooltip={t('admin.audit.proTooltip')} />}
</div>
<p className="text-sm text-muted-foreground">{t('admin.audit.description')}</p>
<div className="space-y-4">
<AdminPageHeader
title={t('admin.audit.title')}
description={t('admin.audit.description')}
badge={!entitlementLoading && !auditEnabled && <ProBadge tooltip={t('admin.audit.proTooltip')} />}
/>
{entitlementLoading ? null : !auditEnabled ? (
<UpgradePrompt />
<UpgradeHint
feature="audit_log"
title={t('admin.audit.upgradeTitle')}
description={t('admin.audit.upgradeDescription')}
actionLabel={t('admin.audit.upgradeButton')}
/>
) : isPending ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
+10 -20
View File
@@ -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<typeof useAdminCloudStoreState> & {
function AdminCloudStoreContent({ state }: { state: AdminCloudStoreReadyState }) {
const { data } = state
return (
<div className="max-w-6xl space-y-5">
<div className="max-w-6xl space-y-4">
<PageHeading settings={data.settings} />
<UpgradeGate available={data.available} />
<AdminTabs state={state} />
@@ -96,28 +96,18 @@ function AdminCloudStoreContent({ state }: { state: AdminCloudStoreReadyState })
function PageHeading({ settings }: { settings: CloudStoreSettings | null }) {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight">{t('admin.cloudStore.title')}</h2>
<ProBadge />
</div>
<p className="text-sm text-muted-foreground">{t('admin.cloudStore.subtitle')}</p>
</div>
<CloudStoreStatusSummary settings={settings} />
</div>
<AdminPageHeader
title={t('admin.cloudStore.title')}
description={t('admin.cloudStore.subtitle')}
badge={<ProBadge />}
action={<CloudStoreStatusSummary settings={settings} />}
/>
)
}
function UpgradeGate({ available }: { available: boolean }) {
if (available) return null
return (
<Card className="border-border/60">
<CardContent className="pt-6">
<UpgradeHint feature="quota_store" />
</CardContent>
</Card>
)
return <UpgradeHint feature="quota_store" />
}
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 (
<div className="flex flex-wrap items-center gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2">
<StatusPill
active={open}
label={t('admin.cloudStore.storeStatus')}