mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(pro): tighten gates and unify upgrade prompts
This commit is contained in:
+1
-1
@@ -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'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user