feat: add quota store UI (#363)

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
Jasper Van
2026-05-05 16:40:54 -04:00
committed by GitHub
parent 41877d384f
commit 8a185c4cd4
18 changed files with 1752 additions and 69 deletions
+160 -6
View File
@@ -80,7 +80,7 @@ describe('Quota Store API', () => {
await expect(filled.json()).resolves.toMatchObject({
enabled: true,
cloudBaseUrl: 'https://cloud.example',
publicInstanceUrl: 'https://zpan.example',
publicInstanceUrl: 'https://zpan.example//',
webhookSigningSecretSet: true,
})
})
@@ -402,6 +402,8 @@ describe('Quota Store API', () => {
amount: 500,
currency: 'usd',
bytes: 4096,
successUrl: 'https://zpan.example/store',
cancelUrl: 'https://zpan.example/store',
})
expect(redemptionBody.code).toBe('CODE-OK')
await expect(decodeSession(redemptionBody.session)).resolves.toMatchObject({
@@ -412,6 +414,92 @@ describe('Quota Store API', () => {
await expect(grants.json()).resolves.toMatchObject({ total: 1, items: [{ orgId, bytes: 512 }] })
})
it('hides self-service packages when the store is disabled', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app, 'buyer@example.com')
await seedSettings(app, headers)
await app.request('/api/admin/quota-store/settings', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: false,
cloudBaseUrl: 'https://cloud.example',
publicInstanceUrl: 'https://zpan.example/',
webhookSigningSecret: SECRET,
}),
})
const orgId = await getFirstOrgId(db)
const packageId = await seedPackage(db)
const packages = await app.request('/api/quota-store/packages', { headers })
const targets = await app.request('/api/quota-store/targets', { headers })
const checkout = await app.request('/api/quota-store/checkout', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ packageId, targetOrgId: orgId }),
})
const redemption = await app.request('/api/quota-store/redemptions', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'CODE-OK', targetOrgId: orgId }),
})
const grants = await app.request('/api/quota-store/grants', { headers })
expect(packages.status).toBe(403)
await expect(packages.json()).resolves.toEqual({ error: 'quota_store_disabled' })
expect(targets.status).toBe(403)
await expect(targets.json()).resolves.toEqual({ error: 'quota_store_disabled' })
expect(checkout.status).toBe(403)
await expect(checkout.json()).resolves.toEqual({ error: 'quota_store_disabled' })
expect(redemption.status).toBe(403)
await expect(redemption.json()).resolves.toEqual({ error: 'quota_store_disabled' })
expect(grants.status).toBe(403)
await expect(grants.json()).resolves.toEqual({ error: 'quota_store_disabled' })
})
it('hides self-service store endpoints until webhook signing is configured', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app, 'buyer@example.com')
await app.request('/api/admin/quota-store/settings', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
cloudBaseUrl: 'https://cloud.example',
publicInstanceUrl: 'https://zpan.example',
}),
})
const orgId = await getFirstOrgId(db)
const packageId = await seedPackage(db)
const packages = await app.request('/api/quota-store/packages', { headers })
const targets = await app.request('/api/quota-store/targets', { headers })
const checkout = await app.request('/api/quota-store/checkout', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ packageId, targetOrgId: orgId }),
})
const redemption = await app.request('/api/quota-store/redemptions', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'CODE-OK', targetOrgId: orgId }),
})
const grants = await app.request('/api/quota-store/grants', { headers })
expect(packages.status).toBe(403)
await expect(packages.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' })
expect(targets.status).toBe(403)
await expect(targets.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' })
expect(checkout.status).toBe(403)
await expect(checkout.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' })
expect(redemption.status).toBe(403)
await expect(redemption.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' })
expect(grants.status).toBe(403)
await expect(grants.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' })
})
it('rejects malformed successful checkout responses', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
@@ -489,7 +577,7 @@ describe('Quota Store API', () => {
eventId: 'evt-1',
cloudOrderId: 'order-1',
targetOrgId: orgId,
packageId,
packageId: 'cloud-pkg-1',
source: 'stripe',
bytes: 4096,
})
@@ -570,7 +658,7 @@ describe('Quota Store API', () => {
eventId: 'evt-invalid-package',
cloudOrderId: 'order-invalid-package',
targetOrgId: orgId,
packageId,
packageId: 'cloud-pkg-1',
source: 'stripe',
bytes: 8192,
})
@@ -594,6 +682,72 @@ describe('Quota Store API', () => {
await expect(retry.json()).resolves.toMatchObject({ success: true, duplicate: false })
})
it('rejects ambiguous delivery package identifiers', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
await seedSettings(app, headers)
const orgId = await getFirstOrgId(db)
const now = Date.now()
await db.run(sql`
INSERT INTO quota_store_packages
(id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at)
VALUES
('pkg-cloud-a', 'Cloud package A', '', 4096, 500, 'usd', 1, 1, 'cloud-pkg-ambiguous', 'synced', ${now}, ${now}),
('pkg-cloud-b', 'Cloud package B', '', 4096, 500, 'usd', 1, 2, 'cloud-pkg-ambiguous', 'synced', ${now}, ${now})
`)
const res = await postWebhook(
app,
JSON.stringify({
eventId: 'evt-ambiguous-package',
cloudOrderId: 'order-ambiguous-package',
targetOrgId: orgId,
packageId: 'cloud-pkg-ambiguous',
source: 'stripe',
bytes: 4096,
}),
)
expect(res.status).toBe(400)
await expect(res.json()).resolves.toEqual({ error: 'invalid_package_delivery' })
})
it('uses exact local package identifiers before Cloud package identifiers', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
await seedSettings(app, headers)
const orgId = await getFirstOrgId(db)
const now = Date.now()
await db.run(sql`
INSERT INTO quota_store_packages
(id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at)
VALUES
('pkg-colliding-id', 'Local package', '', 4096, 500, 'usd', 1, 1, 'cloud-local', 'synced', ${now}, ${now}),
('pkg-cloud-owner', 'Cloud package', '', 4096, 500, 'usd', 1, 2, 'pkg-colliding-id', 'synced', ${now}, ${now})
`)
const res = await postWebhook(
app,
JSON.stringify({
eventId: 'evt-package-id-collision',
cloudOrderId: 'order-package-id-collision',
targetOrgId: orgId,
packageId: 'pkg-colliding-id',
source: 'stripe',
bytes: 4096,
}),
)
expect(res.status).toBe(200)
await expect(res.json()).resolves.toMatchObject({ success: true, duplicate: false })
const grants = await db.all<{ count: number }>(
sql`SELECT COUNT(*) AS count FROM quota_grants WHERE package_snapshot LIKE '%pkg-colliding-id%'`,
)
expect(grants[0].count).toBe(1)
})
it('marks delivery events failed when grant insertion fails', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
@@ -797,7 +951,7 @@ async function seedSettings(app: Awaited<ReturnType<typeof createTestApp>>['app'
body: JSON.stringify({
enabled: true,
cloudBaseUrl: 'https://cloud.example',
publicInstanceUrl: 'https://zpan.example',
publicInstanceUrl: 'https://zpan.example//',
webhookSigningSecret: SECRET,
}),
})
@@ -813,8 +967,8 @@ async function seedPackage(db: Awaited<ReturnType<typeof createTestApp>>['db']):
const now = Date.now()
await db.run(sql`
INSERT INTO quota_store_packages
(id, name, description, bytes, amount, currency, active, sort_order, sync_status, created_at, updated_at)
VALUES (${id}, 'Small', '', 4096, 500, 'usd', 1, 0, 'synced', ${now}, ${now})
(id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at)
VALUES (${id}, 'Small', '', 4096, 500, 'usd', 1, 0, 'cloud-pkg-1', 'synced', ${now}, ${now})
`)
return id
}
+37 -8
View File
@@ -85,11 +85,17 @@ const quotaStore = new Hono<Env>()
.use(requireAuth)
.use(requireFeature('quota_store'))
.get('/packages', async (c) => {
const items = await listQuotaStorePackages(c.get('platform').db, true)
const db = c.get('platform').db
const store = await getUserStoreSettings(db)
if ('error' in store) return c.json({ error: store.error }, 403)
const items = await listQuotaStorePackages(db, true)
return c.json({ items, total: items.length })
})
.get('/targets', async (c) => {
const items = await getAccessibleTargets(c.get('platform').db, c.get('userId')!)
const db = c.get('platform').db
const store = await getUserStoreSettings(db)
if ('error' in store) return c.json({ error: store.error }, 403)
const items = await getAccessibleTargets(db, c.get('userId')!)
return c.json({ items, total: items.length })
})
.post('/checkout', zValidator('json', checkoutInputSchema), async (c) => {
@@ -99,7 +105,9 @@ const quotaStore = new Hono<Env>()
return c.json({ error: 'Forbidden' }, 403)
}
const settings = await getRequiredSettings(db)
const store = await getUserStoreSettings(db)
if ('error' in store) return c.json({ error: store.error }, 403)
const settings = store.settings
const pkg = await getActiveQuotaStorePackage(db, body.packageId)
if (!pkg) return c.json({ error: 'Package not found' }, 404)
const result = await postUserCloud(
@@ -118,7 +126,9 @@ const quotaStore = new Hono<Env>()
return c.json({ error: 'Forbidden' }, 403)
}
const settings = await getRequiredSettings(db)
const store = await getUserStoreSettings(db)
if ('error' in store) return c.json({ error: store.error }, 403)
const settings = store.settings
const result = await postUserCloud(
settings,
'/api/store/redemptions',
@@ -132,7 +142,10 @@ const quotaStore = new Hono<Env>()
return c.json(result)
})
.get('/grants', async (c) => {
const items = await listGrantsForUser(c.get('platform').db, c.get('userId')!)
const db = c.get('platform').db
const store = await getUserStoreSettings(db)
if ('error' in store) return c.json({ error: store.error }, 403)
const items = await listGrantsForUser(db, c.get('userId')!)
return c.json({ items, total: items.length })
})
@@ -162,6 +175,18 @@ const quotaStoreWebhooks = new Hono<Env>().use(requireFeature('quota_store')).po
export { adminQuotaStore, quotaStore, quotaStoreWebhooks }
async function getUserStoreSettings(db: Parameters<typeof getRequiredSettings>[0]) {
try {
return { settings: await getRequiredSettings(db) }
} catch (error) {
const message = (error as Error).message
if (message === 'quota_store_disabled' || message === 'quota_store_webhook_secret_missing') {
return { error: message }
}
throw error
}
}
async function syncPackages(db: Parameters<typeof markPackageSynced>[0], packageId: string) {
try {
const result = await syncCatalog(db)
@@ -183,7 +208,7 @@ async function syncCatalog(db: Parameters<typeof markPackageSynced>[0], excludin
binding.sharedSecret,
{
boundLicenseId: binding.boundLicenseId,
callbackUrl: `${settings.publicInstanceUrl}/api/quota-store/webhooks/cloud`,
callbackUrl: `${publicInstanceUrl(settings)}/api/quota-store/webhooks/cloud`,
packages: packages.map(cloudPackagePayload),
},
cloudPackageSyncResponseSchema,
@@ -309,8 +334,8 @@ async function createCheckoutSession(
amount: pkg.amount,
currency: pkg.currency,
bytes: pkg.bytes,
successUrl: `${settings.publicInstanceUrl}/quota-store/checkout/success`,
cancelUrl: `${settings.publicInstanceUrl}/quota-store/checkout/cancel`,
successUrl: `${publicInstanceUrl(settings)}/store`,
cancelUrl: `${publicInstanceUrl(settings)}/store`,
expiresAt: sessionExpiry(),
},
binding.sharedSecret,
@@ -344,6 +369,10 @@ function sessionExpiry(): string {
return new Date(Date.now() + 15 * 60 * 1000).toISOString()
}
function publicInstanceUrl(settings: Awaited<ReturnType<typeof getRequiredSettings>>): string {
return settings.publicInstanceUrl.replace(/\/+$/, '')
}
function base64Url(value: string): string {
const bytes = new TextEncoder().encode(value)
let binary = ''
+27 -11
View File
@@ -1,6 +1,6 @@
import type { CloudDeliveryEvent, QuotaStorePackageInput, QuotaStoreSettingsInput } from '@shared/schemas'
import type { QuotaGrant, QuotaStorePackage, QuotaStoreSettings, QuotaTarget } from '@shared/types'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { member, organization, user } from '../db/auth-schema'
import { quotaDeliveryEvents, quotaGrants, quotaStorePackages, quotaStoreSettings } from '../db/schema'
@@ -44,13 +44,21 @@ export async function upsertQuotaStoreSettings(
export async function listQuotaStorePackages(db: Database, activeOnly = false): Promise<QuotaStorePackage[]> {
const query = db.select().from(quotaStorePackages)
const rows = activeOnly
? await query
.where(eq(quotaStorePackages.active, true))
.orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name)
? await query.where(purchasablePackageCondition()).orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name)
: await query.orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name)
return rows.map(packageDto)
}
function purchasablePackageCondition(packageId?: string) {
const conditions = [
eq(quotaStorePackages.active, true),
eq(quotaStorePackages.syncStatus, 'synced'),
isNotNull(quotaStorePackages.cloudPackageId),
]
if (packageId) conditions.push(eq(quotaStorePackages.id, packageId))
return and(...conditions)
}
export async function createQuotaStorePackage(db: Database, input: QuotaStorePackageInput): Promise<QuotaStorePackage> {
const now = new Date()
const row = {
@@ -94,11 +102,7 @@ export async function getQuotaStorePackage(db: Database, id: string): Promise<Qu
}
export async function getActiveQuotaStorePackage(db: Database, id: string): Promise<QuotaStorePackage | null> {
const rows = await db
.select()
.from(quotaStorePackages)
.where(and(eq(quotaStorePackages.id, id), eq(quotaStorePackages.active, true)))
.limit(1)
const rows = await db.select().from(quotaStorePackages).where(purchasablePackageCondition(id)).limit(1)
return rows[0] ? packageDto(rows[0]) : null
}
@@ -222,14 +226,26 @@ async function getRawSettings(db: Database) {
async function validatePackageBytes(db: Database, event: CloudDeliveryEvent): Promise<string | null> {
if (event.source === 'stripe' && !event.packageId) throw new Error('package_required')
if (!event.packageId) return null
const rows = await db.select().from(quotaStorePackages).where(eq(quotaStorePackages.id, event.packageId)).limit(1)
const pkg = rows[0]
const pkg = await findDeliveryPackage(db, event.packageId)
if (!pkg || pkg.bytes !== event.bytes || (event.package && event.package.bytes !== pkg.bytes)) {
throw new Error('invalid_package_delivery')
}
return JSON.stringify(event.package ?? packageDto(pkg))
}
async function findDeliveryPackage(db: Database, packageId: string) {
const localRows = await db.select().from(quotaStorePackages).where(eq(quotaStorePackages.id, packageId)).limit(1)
if (localRows[0]) return localRows[0]
const cloudRows = await db
.select()
.from(quotaStorePackages)
.where(eq(quotaStorePackages.cloudPackageId, packageId))
.limit(2)
if (cloudRows.length !== 1) return null
return cloudRows[0]
}
async function validateDeliveryPackage(
db: Database,
eventId: string,
+1 -1
View File
@@ -8,8 +8,8 @@ const FEATURE_LABELS: Record<ProFeature, string> = {
open_registration: 'open registration',
teams_unlimited: 'unlimited teams',
storages_unlimited: 'unlimited storages',
quota_store: 'quota store',
audit_log: 'audit logs',
quota_store: 'storage quota store',
}
interface UpgradeHintProps {
+2
View File
@@ -9,6 +9,7 @@ import {
Megaphone,
Settings,
ShieldCheck,
ShoppingCart,
Users,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -33,6 +34,7 @@ const adminNavItems = [
{ titleKey: 'admin.nav.auth', url: '/admin/settings/oauth', icon: KeyRound },
{ titleKey: 'admin.nav.email', url: '/admin/settings/email', icon: Mail },
{ titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings },
{ titleKey: 'admin.nav.quotaStore', url: '/admin/quota-store', icon: ShoppingCart },
{ titleKey: 'admin.nav.announcement', url: '/admin/announcement', icon: Megaphone },
{ titleKey: 'admin.nav.audit', url: '/admin/audit', icon: ShieldCheck },
{ titleKey: 'admin.nav.licensing', url: '/admin/licensing', icon: BadgeCheck },
@@ -0,0 +1,187 @@
import type { QuotaStorePackageInput } from '@shared/schemas'
import type { QuotaStorePackage } from '@shared/types'
import { Plus } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
const units = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const
type Unit = keyof typeof units
export const emptyPackageForm = {
name: '',
description: '',
size: '100',
unit: 'GB' as Unit,
amount: '999',
currency: 'usd' as 'usd' | 'cny',
active: true,
sortOrder: '0',
}
export type PackageFormState = typeof emptyPackageForm
export function packageInputFromForm(form: PackageFormState): QuotaStorePackageInput {
return {
name: form.name,
description: form.description,
bytes: Math.round(Number(form.size) * units[form.unit]),
amount: Math.round(Number(form.amount)),
currency: form.currency,
active: form.active,
sortOrder: Math.round(Number(form.sortOrder)),
}
}
export function packageFormFromPackage(pkg: QuotaStorePackage): PackageFormState {
const display = bytesToDisplay(pkg.bytes)
return {
name: pkg.name,
description: pkg.description,
size: String(display.size),
unit: display.unit,
amount: String(pkg.amount),
currency: pkg.currency === 'cny' ? 'cny' : 'usd',
active: pkg.active,
sortOrder: String(pkg.sortOrder),
}
}
export function QuotaStorePackageForm({
editing,
form,
available,
pending,
onFormChange,
onCancel,
onSubmit,
}: {
editing: QuotaStorePackage | null
form: PackageFormState
available: boolean
pending: boolean
onFormChange: (form: PackageFormState) => void
onCancel: () => void
onSubmit: () => void
}) {
const { t } = useTranslation()
return (
<Card className="border-border/60">
<CardHeader>
<CardTitle>{editing ? t('admin.quotaStore.editPackage') : t('admin.quotaStore.newPackage')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Field label={t('admin.quotaStore.packageName')} htmlFor="packageName">
<Input id="packageName" value={form.name} onChange={(e) => onFormChange({ ...form, name: e.target.value })} />
</Field>
<Field label={t('admin.quotaStore.description')} htmlFor="packageDescription">
<Textarea
id="packageDescription"
value={form.description}
onChange={(e) => onFormChange({ ...form, description: e.target.value })}
rows={3}
/>
</Field>
<div className="grid grid-cols-[1fr_96px] gap-2">
<Field label={t('admin.quotaStore.size')} htmlFor="packageSize">
<Input
id="packageSize"
type="number"
min="1"
value={form.size}
onChange={(e) => onFormChange({ ...form, size: e.target.value })}
/>
</Field>
<Field label={t('admin.quotaStore.unit')}>
<Select value={form.unit} onValueChange={(unit: Unit) => onFormChange({ ...form, unit })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(units).map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-[1fr_96px] gap-2">
<Field label={t('admin.quotaStore.amount')} htmlFor="packageAmount">
<Input
id="packageAmount"
type="number"
min="1"
value={form.amount}
onChange={(e) => onFormChange({ ...form, amount: e.target.value })}
/>
</Field>
<Field label={t('admin.quotaStore.currency')}>
<Select
value={form.currency}
onValueChange={(currency: 'usd' | 'cny') => onFormChange({ ...form, currency })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="usd">USD</SelectItem>
<SelectItem value="cny">CNY</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field label={t('admin.quotaStore.sortOrder')} htmlFor="packageSortOrder">
<Input
id="packageSortOrder"
type="number"
value={form.sortOrder}
onChange={(e) => onFormChange({ ...form, sortOrder: e.target.value })}
/>
</Field>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor="packageActive">{t('admin.quotaStore.active')}</Label>
<Switch
id="packageActive"
checked={form.active}
onCheckedChange={(active) => onFormChange({ ...form, active })}
/>
</div>
<div className="flex justify-end gap-2">
{editing && (
<Button variant="outline" onClick={onCancel}>
{t('common.cancel')}
</Button>
)}
<Button disabled={!available || pending} onClick={onSubmit}>
<Plus className="mr-2 h-4 w-4" />
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
)
}
function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) {
return (
<div className="space-y-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
</div>
)
}
function bytesToDisplay(bytes: number): { size: number; unit: Unit } {
if (bytes >= units.TB && bytes % units.TB === 0) return { size: bytes / units.TB, unit: 'TB' }
if (bytes >= units.GB && bytes % units.GB === 0) return { size: bytes / units.GB, unit: 'GB' }
return { size: bytes / units.MB, unit: 'MB' }
}
@@ -0,0 +1,64 @@
import type { QuotaStorePackage } from '@shared/types'
import { BadgeAlert, CheckCircle2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { formatSize } from '@/lib/format'
export function QuotaStorePackageList({
packages,
onEdit,
}: {
packages: QuotaStorePackage[]
onEdit: (pkg: QuotaStorePackage) => void
}) {
const { t } = useTranslation()
return (
<div className="space-y-3">
{packages.map((pkg) => (
<Card key={pkg.id} className="border-border/60">
<CardContent className="flex flex-wrap items-start justify-between gap-4 p-4">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium">{pkg.name}</h3>
<Badge variant={pkg.active ? 'default' : 'secondary'}>
{pkg.active ? t('common.active') : t('common.disabled')}
</Badge>
<SyncBadge pkg={pkg} />
</div>
<p className="text-sm text-muted-foreground">{pkg.description}</p>
<p className="text-sm tabular-nums">
{formatSize(pkg.bytes)} · {formatMoney(pkg.amount, pkg.currency)}
</p>
{pkg.syncError && <p className="text-xs text-destructive">{pkg.syncError}</p>}
</div>
<Button variant="outline" onClick={() => onEdit(pkg)}>
{t('common.edit')}
</Button>
</CardContent>
</Card>
))}
{packages.length === 0 && (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
{t('admin.quotaStore.noPackages')}
</div>
)}
</div>
)
}
function SyncBadge({ pkg }: { pkg: QuotaStorePackage }) {
const icon = pkg.syncStatus === 'synced' ? <CheckCircle2 className="h-3 w-3" /> : <BadgeAlert className="h-3 w-3" />
return (
<Badge variant={pkg.syncStatus === 'failed' ? 'destructive' : 'outline'} className="gap-1">
{icon}
{pkg.syncStatus}
</Badge>
)
}
function formatMoney(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}
@@ -0,0 +1,109 @@
import type { QuotaStoreSettings } from '@shared/types'
import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export const emptySettingsForm = {
enabled: false,
cloudBaseUrl: '',
publicInstanceUrl: '',
webhookSigningSecret: '',
}
export type SettingsFormState = typeof emptySettingsForm
export function settingsInput(form: SettingsFormState) {
return {
enabled: form.enabled,
cloudBaseUrl: form.cloudBaseUrl,
publicInstanceUrl: form.publicInstanceUrl,
...(form.webhookSigningSecret ? { webhookSigningSecret: form.webhookSigningSecret } : {}),
}
}
export function QuotaStoreSettingsPanel({
available,
settings,
form,
pending,
onFormChange,
onSave,
}: {
available: boolean
settings: QuotaStoreSettings | null
form: SettingsFormState
pending: boolean
onFormChange: (form: SettingsFormState) => void
onSave: () => void
}) {
const { t } = useTranslation()
const callbackUrl = form.publicInstanceUrl
? `${form.publicInstanceUrl.replace(/\/+$/, '')}/api/quota-store/webhooks/cloud`
: ''
return (
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('admin.quotaStore.cloudTitle')}</CardTitle>
<CardDescription>{t('admin.quotaStore.cloudDescription')}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-[1fr_180px]">
<div className="space-y-2">
<Label>{t('admin.quotaStore.cloudBaseUrl')}</Label>
<Input
value={form.cloudBaseUrl}
onChange={(e) => onFormChange({ ...form, cloudBaseUrl: e.target.value })}
disabled={!available}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>{t('admin.quotaStore.publicInstanceUrl')}</Label>
<Input
value={form.publicInstanceUrl}
onChange={(e) => onFormChange({ ...form, publicInstanceUrl: e.target.value })}
disabled={!available}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>{t('admin.quotaStore.callbackUrl')}</Label>
<div className="flex gap-2">
<Input value={callbackUrl} readOnly />
<Button
type="button"
variant="outline"
size="icon"
disabled={!callbackUrl}
onClick={() => navigator.clipboard.writeText(callbackUrl)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('admin.quotaStore.signing')}</Label>
<Badge variant={settings?.webhookSigningSecretSet ? 'default' : 'secondary'}>
{settings?.webhookSigningSecretSet ? t('common.configured') : t('common.notConfigured')}
</Badge>
</div>
<div className="space-y-2">
<Label>{t('admin.quotaStore.webhookSecret')}</Label>
<Input
type="password"
value={form.webhookSigningSecret}
onChange={(e) => onFormChange({ ...form, webhookSigningSecret: e.target.value })}
disabled={!available}
/>
</div>
<div className="flex justify-end md:col-span-2">
<Button disabled={!available || pending} onClick={onSave}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
)
}
+3 -41
View File
@@ -5,7 +5,6 @@ import {
ChevronsUpDown,
FileText,
FolderOpen,
HardDrive,
Image,
LogOut,
Music,
@@ -40,18 +39,11 @@ import {
SidebarSeparator,
} from '@/components/ui/sidebar'
import { useSiteOptions } from '@/hooks/use-site-options'
import { getIhostConfig, getUserQuota } from '@/lib/api'
import { getIhostConfig } from '@/lib/api'
import { signOut, useActiveOrganization, useSession } from '@/lib/auth-client'
import { OrgSwitcher } from '../team/org-switcher'
import { FolderTree } from './folder-tree'
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const value = bytes / 1024 ** i
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
}
import { QuotaPanel } from './quota-panel'
function getInitials(name: string): string {
return name
@@ -71,11 +63,6 @@ export function AppSidebar() {
const { branding } = useBranding()
const user = session?.user as { name: string; username?: string; role?: string; image?: string | null } | undefined
const isAdmin = user?.role === 'admin'
const { data: quota } = useQuery({
queryKey: ['user', 'quota'],
queryFn: getUserQuota,
enabled: !!session,
})
const { data: ihostConfig } = useQuery({
queryKey: ['ihost', 'config', activeOrg?.id],
queryFn: getIhostConfig,
@@ -202,32 +189,7 @@ export function AppSidebar() {
</a>
</div>
)}
{quota && (
<div className="border-t px-5 py-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{t('quota.storage')}</span>
{quota.quota > 0 && (
<span className="ml-auto tabular-nums text-muted-foreground">
{Math.round((quota.used / quota.quota) * 100)}%
</span>
)}
</div>
{quota.quota > 0 && (
<div className="mb-1.5 h-2 rounded-full bg-border overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, (quota.used / quota.quota) * 100)}%` }}
/>
</div>
)}
<p className="text-xs text-muted-foreground tabular-nums">
{quota.quota > 0
? t('quota.usage', { used: formatSize(quota.used), total: formatSize(quota.quota) })
: t('quota.usageNoLimit', { used: formatSize(quota.used) })}
</p>
</div>
)}
<QuotaPanel enabled={!!session} />
<SidebarFooter className="border-t p-3">
<SidebarMenu>
<SidebarMenuItem>
+126
View File
@@ -0,0 +1,126 @@
import type { QuotaGrant, QuotaStorePackage } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, render, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getUserQuota, listPurchasableQuotaPackages, listQuotaGrants } from '@/lib/api'
import { QuotaPanel } from './quota-panel'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => (values?.amount ? `${key}:${values.amount}` : key),
}),
}))
vi.mock('@tanstack/react-router', () => ({
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
}))
vi.mock('@/lib/api', () => ({
getUserQuota: vi.fn(),
listPurchasableQuotaPackages: vi.fn(),
listQuotaGrants: vi.fn(),
}))
function quotaPackage(): QuotaStorePackage {
return {
id: 'pkg-1',
name: '100 GB',
description: 'Extra storage',
bytes: 107374182400,
amount: 999,
currency: 'usd',
active: true,
sortOrder: 1,
cloudPackageId: 'cloud-pkg-1',
syncStatus: 'synced',
syncError: null,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
}
}
function grant(overrides: Partial<QuotaGrant> = {}): QuotaGrant {
return {
id: 'grant-1',
orgId: 'org-1',
source: 'stripe',
externalEventId: null,
cloudOrderId: null,
cloudRedemptionId: null,
code: null,
bytes: 107374182400,
packageSnapshot: null,
grantedBy: null,
terminalUserId: null,
terminalUserEmail: null,
active: true,
createdAt: '2026-05-05T00:00:00.000Z',
...overrides,
}
}
function renderQuotaPanel() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<QuotaPanel enabled />
</QueryClientProvider>,
)
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('QuotaPanel', () => {
it('hides the store entry when the store is unavailable', async () => {
vi.mocked(getUserQuota).mockResolvedValue({ orgId: 'org-1', baseQuota: 100, grantedQuota: 0, quota: 100, used: 25 })
vi.mocked(listPurchasableQuotaPackages).mockRejectedValue(new Error('quota_store_disabled'))
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
const view = renderQuotaPanel()
await waitFor(() => expect(view.getByText('quota.storage')).toBeTruthy())
expect(view.queryByText('nav.store')).toBeNull()
expect(listQuotaGrants).not.toHaveBeenCalled()
})
it('shows the store entry when redemption is available without packages', async () => {
vi.mocked(getUserQuota).mockResolvedValue({ orgId: 'org-1', baseQuota: 100, grantedQuota: 0, quota: 100, used: 25 })
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
const view = renderQuotaPanel()
await waitFor(() => expect(view.getByText('nav.store')).toBeTruthy())
expect(listQuotaGrants).toHaveBeenCalled()
})
it('shows the store entry and matching purchased storage', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
baseQuota: 100,
grantedQuota: 100,
quota: 200,
used: 25,
})
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [quotaPackage()], total: 1 })
vi.mocked(listQuotaGrants).mockResolvedValue({
items: [grant(), grant({ id: 'grant-2', orgId: 'org-2' }), grant({ id: 'grant-3', active: false })],
total: 3,
})
const view = renderQuotaPanel()
await waitFor(() => expect(view.getByText('nav.store')).toBeTruthy())
await waitFor(() => expect(view.getByText('quota.purchased:100 GB')).toBeTruthy())
})
})
+81
View File
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { HardDrive, PlusCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { getUserQuota, listPurchasableQuotaPackages, listQuotaGrants } from '@/lib/api'
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const value = bytes / 1024 ** i
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
}
export function QuotaPanel({ enabled }: { enabled: boolean }) {
const { t } = useTranslation()
const { data: quota } = useQuery({
queryKey: ['user', 'quota'],
queryFn: getUserQuota,
enabled,
})
const packagesQuery = useQuery({
queryKey: ['quota-store', 'packages'],
queryFn: listPurchasableQuotaPackages,
enabled,
retry: false,
})
const { data: grants } = useQuery({
queryKey: ['quota-store', 'grants'],
queryFn: listQuotaGrants,
enabled: enabled && packagesQuery.isSuccess,
})
if (!quota) return null
const purchasedBytes = (grants?.items ?? [])
.filter((grant) => grant.active && grant.orgId === quota.orgId)
.reduce((sum, grant) => sum + grant.bytes, 0)
const hasStore = packagesQuery.isSuccess
return (
<div className="border-t px-5 py-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<HardDrive className="h-3.5 w-3.5" />
<span>{t('quota.storage')}</span>
{quota.quota > 0 && (
<span className="ml-auto tabular-nums text-muted-foreground">
{Math.round((quota.used / quota.quota) * 100)}%
</span>
)}
</div>
{quota.quota > 0 && (
<div className="mb-1.5 h-2 rounded-full bg-border overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, (quota.used / quota.quota) * 100)}%` }}
/>
</div>
)}
<p className="text-xs text-muted-foreground tabular-nums">
{quota.quota > 0
? t('quota.usage', { used: formatSize(quota.used), total: formatSize(quota.quota) })
: t('quota.usageNoLimit', { used: formatSize(quota.used) })}
</p>
{purchasedBytes > 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('quota.purchased', { amount: formatSize(purchasedBytes) })}
</p>
)}
{hasStore && (
<Button variant="outline" size="sm" className="mt-3 w-full justify-start" asChild>
<Link to="/store">
<PlusCircle className="mr-2 h-4 w-4" />
{t('nav.store')}
</Link>
</Button>
)}
</div>
)
}
+45 -1
View File
@@ -38,6 +38,7 @@
"nav.documents": "Documents",
"nav.trash": "Trash",
"nav.imageHost": "Image Host",
"nav.store": "Add storage",
"nav.settings": "Settings",
"nav.adminPanel": "Admin Panel",
"nav.teams": "Teams",
@@ -135,6 +136,7 @@
"admin.nav.audit": "Audit Logs",
"admin.nav.users": "Users",
"admin.nav.settings": "Settings",
"admin.nav.quotaStore": "Quota Store",
"admin.nav.email": "Email",
"admin.overview.title": "Admin overview",
"admin.overview.subtitle": "Key operational signals for users, storage, quota, and invitations.",
@@ -497,12 +499,17 @@
"quota.storage": "Storage",
"quota.usage": "{{used}} / {{total}} used",
"quota.usageNoLimit": "{{used}} used",
"quota.purchased": "{{amount}} purchased or granted",
"common.copied": "Copied",
"common.create": "Create",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.active": "Active",
"common.disabled": "Disabled",
"common.configured": "Configured",
"common.notConfigured": "Not configured",
"common.confirm": "Confirm",
"common.close": "Close",
"common.loading": "Loading...",
@@ -949,6 +956,7 @@
"features.imageHosting": "Image Hosting",
"features.whiteLabel": "Custom Branding",
"features.auditLog": "Audit Logs",
"features.quotaStore": "Storage Quota Store",
"features.webhooks": "Event Webhooks",
"features.analytics": "Analytics",
"settings.billing.pairing.title": "Connect to ZPan Cloud",
@@ -993,5 +1001,41 @@
"settings.billing.bound.disconnectTitle": "Remove Pro License",
"settings.billing.bound.disconnectConfirm": "This will remove the local Pro license certificate and Cloud binding. Pro features will be locked immediately.",
"settings.billing.bound.disconnectSuccess": "Pro license removed",
"settings.billing.bound.disconnectError": "Failed to remove license"
"settings.billing.bound.disconnectError": "Failed to remove license",
"admin.quotaStore.title": "Quota Store",
"admin.quotaStore.subtitle": "Configure storage packages users can purchase or redeem for personal and team spaces.",
"admin.quotaStore.enabled": "Store enabled",
"admin.quotaStore.saved": "Quota store settings saved",
"admin.quotaStore.packageSaved": "Package saved",
"admin.quotaStore.cloudTitle": "Cloud callback",
"admin.quotaStore.cloudDescription": "Use this callback URL in ZPan Cloud and confirm webhook signing is configured.",
"admin.quotaStore.cloudBaseUrl": "Cloud base URL",
"admin.quotaStore.publicInstanceUrl": "Public instance URL",
"admin.quotaStore.callbackUrl": "Callback URL",
"admin.quotaStore.signing": "Signing",
"admin.quotaStore.webhookSecret": "Webhook signing secret",
"admin.quotaStore.newPackage": "New package",
"admin.quotaStore.editPackage": "Edit package",
"admin.quotaStore.packageName": "Name",
"admin.quotaStore.description": "Description",
"admin.quotaStore.size": "Size",
"admin.quotaStore.unit": "Unit",
"admin.quotaStore.amount": "Amount (minor units)",
"admin.quotaStore.currency": "Currency",
"admin.quotaStore.sortOrder": "Sort order",
"admin.quotaStore.active": "Active",
"admin.quotaStore.noPackages": "No quota store packages configured.",
"store.title": "Storage Store",
"store.subtitle": "Buy or redeem additional storage for a personal space or team.",
"store.unavailable": "The storage store is not available on this site.",
"store.target": "Target space",
"store.checkout": "Checkout",
"store.redeemTitle": "Redeem storage code",
"store.redeemDescription": "Apply a storage code to the selected personal or team space.",
"store.storageCode": "Storage code",
"store.redeemButton": "Redeem",
"store.redeemed": "Storage code redeemed",
"store.historyTitle": "Recent grants",
"store.historyDescription": "Recent purchased and redeemed storage for spaces you can access.",
"store.noHistory": "No storage grants yet."
}
+45 -1
View File
@@ -38,6 +38,7 @@
"nav.documents": "文档",
"nav.trash": "回收站",
"nav.imageHost": "图床",
"nav.store": "增加存储",
"nav.settings": "设置",
"nav.adminPanel": "管理后台",
"nav.teams": "团队",
@@ -135,6 +136,7 @@
"admin.nav.audit": "审计日志",
"admin.nav.users": "用户",
"admin.nav.settings": "设置",
"admin.nav.quotaStore": "配额商店",
"admin.nav.email": "邮件",
"admin.overview.title": "管理后台概览",
"admin.overview.subtitle": "集中查看用户、存储、配额和邀请的关键运营状态。",
@@ -497,12 +499,17 @@
"quota.storage": "存储空间",
"quota.usage": "{{used}} / {{total}} 已使用",
"quota.usageNoLimit": "{{used}} 已使用",
"quota.purchased": "已购买或获赠 {{amount}}",
"common.copied": "已复制",
"common.create": "创建",
"common.save": "保存",
"common.cancel": "取消",
"common.delete": "删除",
"common.edit": "编辑",
"common.active": "启用",
"common.disabled": "停用",
"common.configured": "已配置",
"common.notConfigured": "未配置",
"common.confirm": "确认",
"common.close": "关闭",
"common.loading": "加载中...",
@@ -949,6 +956,7 @@
"features.imageHosting": "基础图床功能",
"features.whiteLabel": "自定义品牌",
"features.auditLog": "审计日志",
"features.quotaStore": "存储配额商店",
"features.webhooks": "事件 Webhooks",
"features.analytics": "统计分析",
"settings.billing.pairing.title": "连接 ZPan Cloud",
@@ -993,5 +1001,41 @@
"settings.billing.bound.disconnectTitle": "移除 Pro License",
"settings.billing.bound.disconnectConfirm": "这将移除本地 Pro 授权证书和云端绑定,Pro 功能将立即锁定。",
"settings.billing.bound.disconnectSuccess": "已移除 Pro 授权",
"settings.billing.bound.disconnectError": "移除授权失败"
"settings.billing.bound.disconnectError": "移除授权失败",
"admin.quotaStore.title": "配额商店",
"admin.quotaStore.subtitle": "配置用户可购买或兑换到个人空间和团队空间的存储套餐。",
"admin.quotaStore.enabled": "启用商店",
"admin.quotaStore.saved": "配额商店设置已保存",
"admin.quotaStore.packageSaved": "套餐已保存",
"admin.quotaStore.cloudTitle": "云端回调",
"admin.quotaStore.cloudDescription": "在 ZPan Cloud 中使用此回调 URL,并确认已配置 Webhook 签名。",
"admin.quotaStore.cloudBaseUrl": "云端基础 URL",
"admin.quotaStore.publicInstanceUrl": "公开实例 URL",
"admin.quotaStore.callbackUrl": "回调 URL",
"admin.quotaStore.signing": "签名",
"admin.quotaStore.webhookSecret": "Webhook 签名密钥",
"admin.quotaStore.newPackage": "新建套餐",
"admin.quotaStore.editPackage": "编辑套餐",
"admin.quotaStore.packageName": "名称",
"admin.quotaStore.description": "描述",
"admin.quotaStore.size": "容量",
"admin.quotaStore.unit": "单位",
"admin.quotaStore.amount": "金额(最小货币单位)",
"admin.quotaStore.currency": "货币",
"admin.quotaStore.sortOrder": "排序",
"admin.quotaStore.active": "启用",
"admin.quotaStore.noPackages": "暂无配额商店套餐。",
"store.title": "存储商店",
"store.subtitle": "为个人空间或团队购买、兑换额外存储空间。",
"store.unavailable": "当前站点未开放存储商店。",
"store.target": "目标空间",
"store.checkout": "结账",
"store.redeemTitle": "兑换存储码",
"store.redeemDescription": "将存储码应用到所选个人空间或团队空间。",
"store.storageCode": "存储码",
"store.redeemButton": "兑换",
"store.redeemed": "存储码已兑换",
"store.historyTitle": "最近授予",
"store.historyDescription": "你可访问空间的近期购买和兑换记录。",
"store.noHistory": "暂无存储授予记录。"
}
+43
View File
@@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as UUsernameRouteImport } from './routes/u/$username'
import { Route as STokenRouteImport } from './routes/s/$token'
import { Route as AuthenticatedStoreRouteImport } from './routes/_authenticated/store'
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
@@ -32,6 +33,7 @@ import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_auth
import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_authenticated/settings/password'
import { Route as AuthenticatedSettingsIhostRouteImport } from './routes/_authenticated/settings/ihost'
import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
import { Route as AuthenticatedAdminQuotaStoreRouteImport } from './routes/_authenticated/admin/quota-store'
import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing'
import { Route as AuthenticatedAdminAuditRouteImport } from './routes/_authenticated/admin/audit'
import { Route as AuthenticatedAdminAnnouncementRouteImport } from './routes/_authenticated/admin/announcement'
@@ -70,6 +72,11 @@ const STokenRoute = STokenRouteImport.update({
path: '/$token',
getParentRoute: () => SRouteRoute,
} as any)
const AuthenticatedStoreRoute = AuthenticatedStoreRouteImport.update({
id: '/store',
path: '/store',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const authSignUpRoute = authSignUpRouteImport.update({
id: '/(auth)/sign-up',
path: '/sign-up',
@@ -170,6 +177,12 @@ const AuthenticatedSettingsAppearanceRoute =
path: '/appearance',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedAdminQuotaStoreRoute =
AuthenticatedAdminQuotaStoreRouteImport.update({
id: '/quota-store',
path: '/quota-store',
getParentRoute: () => AuthenticatedAdminRouteRoute,
} as any)
const AuthenticatedAdminLicensingRoute =
AuthenticatedAdminLicensingRouteImport.update({
id: '/licensing',
@@ -255,12 +268,14 @@ export interface FileRoutesByFullPath {
'/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
'/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/store': typeof AuthenticatedStoreRoute
'/s/$token': typeof STokenRoute
'/u/$username': typeof UUsernameRoute
'/teams/$teamId': typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
'/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
'/admin/audit': typeof AuthenticatedAdminAuditRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/admin/quota-store': typeof AuthenticatedAdminQuotaStoreRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/settings/ihost': typeof AuthenticatedSettingsIhostRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
@@ -289,12 +304,14 @@ export interface FileRoutesByTo {
'/s': typeof SRouteRouteWithChildren
'/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/store': typeof AuthenticatedStoreRoute
'/s/$token': typeof STokenRoute
'/u/$username': typeof UUsernameRoute
'/': typeof AuthenticatedIndexRoute
'/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
'/admin/audit': typeof AuthenticatedAdminAuditRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/admin/quota-store': typeof AuthenticatedAdminQuotaStoreRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/settings/ihost': typeof AuthenticatedSettingsIhostRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
@@ -327,6 +344,7 @@ export interface FileRoutesById {
'/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
'/(auth)/sign-in': typeof authSignInRoute
'/(auth)/sign-up': typeof authSignUpRoute
'/_authenticated/store': typeof AuthenticatedStoreRoute
'/s/$token': typeof STokenRoute
'/u/$username': typeof UUsernameRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
@@ -334,6 +352,7 @@ export interface FileRoutesById {
'/_authenticated/admin/announcement': typeof AuthenticatedAdminAnnouncementRoute
'/_authenticated/admin/audit': typeof AuthenticatedAdminAuditRoute
'/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/_authenticated/admin/quota-store': typeof AuthenticatedAdminQuotaStoreRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/_authenticated/settings/ihost': typeof AuthenticatedSettingsIhostRoute
'/_authenticated/settings/password': typeof AuthenticatedSettingsPasswordRoute
@@ -367,12 +386,14 @@ export interface FileRouteTypes {
| '/settings'
| '/sign-in'
| '/sign-up'
| '/store'
| '/s/$token'
| '/u/$username'
| '/teams/$teamId'
| '/admin/announcement'
| '/admin/audit'
| '/admin/licensing'
| '/admin/quota-store'
| '/settings/appearance'
| '/settings/ihost'
| '/settings/password'
@@ -401,12 +422,14 @@ export interface FileRouteTypes {
| '/s'
| '/sign-in'
| '/sign-up'
| '/store'
| '/s/$token'
| '/u/$username'
| '/'
| '/admin/announcement'
| '/admin/audit'
| '/admin/licensing'
| '/admin/quota-store'
| '/settings/appearance'
| '/settings/ihost'
| '/settings/password'
@@ -438,6 +461,7 @@ export interface FileRouteTypes {
| '/_authenticated/settings'
| '/(auth)/sign-in'
| '/(auth)/sign-up'
| '/_authenticated/store'
| '/s/$token'
| '/u/$username'
| '/_authenticated/'
@@ -445,6 +469,7 @@ export interface FileRouteTypes {
| '/_authenticated/admin/announcement'
| '/_authenticated/admin/audit'
| '/_authenticated/admin/licensing'
| '/_authenticated/admin/quota-store'
| '/_authenticated/settings/appearance'
| '/_authenticated/settings/ihost'
| '/_authenticated/settings/password'
@@ -515,6 +540,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof STokenRouteImport
parentRoute: typeof SRouteRoute
}
'/_authenticated/store': {
id: '/_authenticated/store'
path: '/store'
fullPath: '/store'
preLoaderRoute: typeof AuthenticatedStoreRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/(auth)/sign-up': {
id: '/(auth)/sign-up'
path: '/sign-up'
@@ -641,6 +673,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsAppearanceRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/admin/quota-store': {
id: '/_authenticated/admin/quota-store'
path: '/quota-store'
fullPath: '/admin/quota-store'
preLoaderRoute: typeof AuthenticatedAdminQuotaStoreRouteImport
parentRoute: typeof AuthenticatedAdminRouteRoute
}
'/_authenticated/admin/licensing': {
id: '/_authenticated/admin/licensing'
path: '/licensing'
@@ -739,6 +778,7 @@ interface AuthenticatedAdminRouteRouteChildren {
AuthenticatedAdminAnnouncementRoute: typeof AuthenticatedAdminAnnouncementRoute
AuthenticatedAdminAuditRoute: typeof AuthenticatedAdminAuditRoute
AuthenticatedAdminLicensingRoute: typeof AuthenticatedAdminLicensingRoute
AuthenticatedAdminQuotaStoreRoute: typeof AuthenticatedAdminQuotaStoreRoute
AuthenticatedAdminIndexRoute: typeof AuthenticatedAdminIndexRoute
AuthenticatedAdminSettingsEmailRoute: typeof AuthenticatedAdminSettingsEmailRoute
AuthenticatedAdminSettingsOauthRoute: typeof AuthenticatedAdminSettingsOauthRoute
@@ -752,6 +792,7 @@ const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren
AuthenticatedAdminAnnouncementRoute: AuthenticatedAdminAnnouncementRoute,
AuthenticatedAdminAuditRoute: AuthenticatedAdminAuditRoute,
AuthenticatedAdminLicensingRoute: AuthenticatedAdminLicensingRoute,
AuthenticatedAdminQuotaStoreRoute: AuthenticatedAdminQuotaStoreRoute,
AuthenticatedAdminIndexRoute: AuthenticatedAdminIndexRoute,
AuthenticatedAdminSettingsEmailRoute: AuthenticatedAdminSettingsEmailRoute,
AuthenticatedAdminSettingsOauthRoute: AuthenticatedAdminSettingsOauthRoute,
@@ -812,6 +853,7 @@ const AuthenticatedTeamsTeamIdRouteRouteWithChildren =
interface AuthenticatedRouteRouteChildren {
AuthenticatedAdminRouteRoute: typeof AuthenticatedAdminRouteRouteWithChildren
AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
AuthenticatedStoreRoute: typeof AuthenticatedStoreRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedTeamsTeamIdRouteRoute: typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
AuthenticatedTeamsInviteRoute: typeof AuthenticatedTeamsInviteRoute
@@ -827,6 +869,7 @@ interface AuthenticatedRouteRouteChildren {
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedAdminRouteRoute: AuthenticatedAdminRouteRouteWithChildren,
AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
AuthenticatedStoreRoute: AuthenticatedStoreRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedTeamsTeamIdRouteRoute:
AuthenticatedTeamsTeamIdRouteRouteWithChildren,
@@ -0,0 +1,165 @@
import type { QuotaStorePackage, QuotaStoreSettings } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiError,
createQuotaStorePackage,
getQuotaStoreSettings,
listQuotaStorePackages,
updateQuotaStoreSettings,
} from '@/lib/api'
import { AdminQuotaStorePage } from './quota-store'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/components/ProBadge', () => ({
ProBadge: () => <span>pro-badge</span>,
}))
vi.mock('@/components/UpgradeHint', () => ({
UpgradeHint: ({ feature }: { feature: string }) => <div>upgrade:{feature}</div>,
}))
vi.mock('@/lib/api', () => {
class MockApiError extends Error {
readonly status: number
readonly body: Record<string, unknown>
constructor(status: number, body: Record<string, unknown>) {
super(String(body.error ?? `HTTP ${status}`))
this.name = 'ApiError'
this.status = status
this.body = body
}
}
return {
ApiError: MockApiError,
createQuotaStorePackage: vi.fn(),
getQuotaStoreSettings: vi.fn(),
listQuotaStorePackages: vi.fn(),
updateQuotaStorePackage: vi.fn(),
updateQuotaStoreSettings: vi.fn(),
}
})
function settings(overrides: Partial<QuotaStoreSettings> = {}): QuotaStoreSettings {
return {
id: 'settings-1',
enabled: true,
cloudBaseUrl: 'https://cloud.example',
publicInstanceUrl: 'https://zpan.example',
webhookSigningSecretSet: true,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
...overrides,
}
}
function quotaPackage(overrides: Partial<QuotaStorePackage> = {}): QuotaStorePackage {
return {
id: 'pkg-1',
name: '100 GB',
description: 'Extra storage',
bytes: 107374182400,
amount: 999,
currency: 'usd',
active: true,
sortOrder: 1,
cloudPackageId: 'cloud-pkg-1',
syncStatus: 'synced',
syncError: null,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
...overrides,
}
}
function renderAdminPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<AdminQuotaStorePage />
</QueryClientProvider>,
)
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('AdminQuotaStorePage', () => {
it('shows the Pro gate when quota store settings are unavailable', async () => {
vi.mocked(getQuotaStoreSettings).mockRejectedValue(new ApiError(402, { error: 'feature_not_available' }))
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
const view = renderAdminPage()
await waitFor(() => expect(view.getByText('upgrade:quota_store')).toBeTruthy())
expect(view.getByRole('switch', { name: 'admin.quotaStore.enabled' }).hasAttribute('disabled')).toBe(true)
})
it('creates a package with the configured form values', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({
items: [quotaPackage({ syncStatus: 'failed', syncError: 'sync failed' })],
total: 1,
})
vi.mocked(createQuotaStorePackage).mockResolvedValue(quotaPackage({ id: 'pkg-2' }))
vi.mocked(updateQuotaStoreSettings).mockResolvedValue(settings())
const view = renderAdminPage()
await waitFor(() => expect(view.getByText('sync failed')).toBeTruthy())
fireEvent.change(view.getByLabelText('admin.quotaStore.packageName'), { target: { value: '250 GB' } })
fireEvent.change(view.getByLabelText('admin.quotaStore.description'), { target: { value: 'Team storage' } })
fireEvent.change(view.getByLabelText('admin.quotaStore.size'), { target: { value: '250' } })
fireEvent.change(view.getByLabelText('admin.quotaStore.amount'), { target: { value: '1999' } })
fireEvent.change(view.getByLabelText('admin.quotaStore.sortOrder'), { target: { value: '2' } })
fireEvent.click(view.getAllByRole('button', { name: 'common.save' })[1])
await waitFor(() =>
expect(createQuotaStorePackage).toHaveBeenCalledWith({
name: '250 GB',
description: 'Team storage',
bytes: 268435456000,
amount: 1999,
currency: 'usd',
active: true,
sortOrder: 2,
}),
)
expect(toast.success).toHaveBeenCalledWith('admin.quotaStore.packageSaved')
})
it('normalizes the displayed callback URL', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings({ publicInstanceUrl: 'https://zpan.example//' }))
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
const view = renderAdminPage()
await waitFor(() =>
expect(view.getByDisplayValue('https://zpan.example/api/quota-store/webhooks/cloud')).toBeTruthy(),
)
})
})
@@ -0,0 +1,166 @@
import type { QuotaStorePackage, QuotaStoreSettings } from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
emptyPackageForm,
packageFormFromPackage,
packageInputFromForm,
QuotaStorePackageForm,
} from '@/components/admin/quota-store-package-form'
import { QuotaStorePackageList } from '@/components/admin/quota-store-package-list'
import {
emptySettingsForm,
QuotaStoreSettingsPanel,
settingsInput,
} from '@/components/admin/quota-store-settings-panel'
import { ProBadge } from '@/components/ProBadge'
import { UpgradeHint } from '@/components/UpgradeHint'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
ApiError,
createQuotaStorePackage,
getQuotaStoreSettings,
listQuotaStorePackages,
updateQuotaStorePackage,
updateQuotaStoreSettings,
} from '@/lib/api'
export const Route = createFileRoute('/_authenticated/admin/quota-store')({
component: AdminQuotaStorePage,
})
export function AdminQuotaStorePage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [editing, setEditing] = useState<QuotaStorePackage | null>(null)
const [form, setForm] = useState(emptyPackageForm)
const [settingsForm, setSettingsForm] = useState(emptySettingsForm)
const query = useQuery({ queryKey: ['admin', 'quota-store'], queryFn: loadAdminQuotaStore })
const data = query.data
useEffect(() => {
if (!data?.settings) return
setSettingsForm({
enabled: data.settings.enabled,
cloudBaseUrl: data.settings.cloudBaseUrl,
publicInstanceUrl: data.settings.publicInstanceUrl,
webhookSigningSecret: '',
})
}, [data?.settings])
const settingsMutation = useMutation({
mutationFn: (nextSettings: typeof emptySettingsForm) => updateQuotaStoreSettings(settingsInput(nextSettings)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'quota-store'] })
toast.success(t('admin.quotaStore.saved'))
},
onError: (err) => toast.error(err.message),
})
const packageMutation = useMutation({
mutationFn: () => {
const input = packageInputFromForm(form)
return editing ? updateQuotaStorePackage(editing.id, input) : createQuotaStorePackage(input)
},
onSuccess: () => {
setEditing(null)
setForm(emptyPackageForm)
queryClient.invalidateQueries({ queryKey: ['admin', 'quota-store'] })
toast.success(t('admin.quotaStore.packageSaved'))
},
onError: (err) => toast.error(err.message),
})
if (query.isLoading) return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
if (!data) return null
function editPackage(pkg: QuotaStorePackage) {
setEditing(pkg)
setForm(packageFormFromPackage(pkg))
}
return (
<div className="max-w-5xl space-y-6">
<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.quotaStore.title')}</h2>
<ProBadge />
</div>
<p className="text-sm text-muted-foreground">{t('admin.quotaStore.subtitle')}</p>
</div>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Label htmlFor="storeEnabled" className="text-sm">
{t('admin.quotaStore.enabled')}
</Label>
<Switch
id="storeEnabled"
checked={data.enabled}
disabled={!data.available || !data.settings || settingsMutation.isPending}
onCheckedChange={(enabled) => {
const nextSettings = { ...settingsForm, enabled }
setSettingsForm(nextSettings)
settingsMutation.mutate(nextSettings)
}}
/>
</div>
</div>
{!data.available && (
<Card className="border-border/60">
<CardContent className="pt-6">
<UpgradeHint feature="quota_store" />
</CardContent>
</Card>
)}
<QuotaStoreSettingsPanel
available={data.available}
settings={data.settings}
form={settingsForm}
pending={settingsMutation.isPending}
onFormChange={setSettingsForm}
onSave={() => settingsMutation.mutate(settingsForm)}
/>
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<QuotaStorePackageForm
editing={editing}
form={form}
available={data.available}
pending={packageMutation.isPending}
onFormChange={setForm}
onCancel={() => {
setEditing(null)
setForm(emptyPackageForm)
}}
onSubmit={() => packageMutation.mutate()}
/>
<QuotaStorePackageList packages={data.packages} onEdit={editPackage} />
</div>
</div>
)
}
async function loadAdminQuotaStore(): Promise<{
available: boolean
enabled: boolean
settings: QuotaStoreSettings | null
packages: QuotaStorePackage[]
}> {
try {
const [settings, packages] = await Promise.all([getQuotaStoreSettings(), listQuotaStorePackages()])
return { available: true, enabled: settings?.enabled ?? false, settings, packages: packages.items }
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
return { available: false, enabled: false, settings: null, packages: [] }
}
throw err
}
}
+218
View File
@@ -0,0 +1,218 @@
import type { QuotaGrant, QuotaStorePackage } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createQuotaCheckout,
listPurchasableQuotaPackages,
listQuotaGrants,
listQuotaStoreTargets,
redeemQuotaCode,
} from '@/lib/api'
import { StorePage } from './store'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: { amount?: string }) => (values?.amount ? `${key}:${values.amount}` : key),
}),
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/lib/api', () => ({
createQuotaCheckout: vi.fn(),
listPurchasableQuotaPackages: vi.fn(),
listQuotaGrants: vi.fn(),
listQuotaStoreTargets: vi.fn(),
redeemQuotaCode: vi.fn(),
}))
function grant(overrides: Partial<QuotaGrant> = {}): QuotaGrant {
return {
id: 'grant-1',
orgId: 'org-1',
source: 'stripe' as const,
externalEventId: null,
cloudOrderId: null,
cloudRedemptionId: null,
code: null,
bytes: 1024,
packageSnapshot: null,
grantedBy: null,
terminalUserId: null,
terminalUserEmail: null,
active: true,
createdAt: '2026-05-05T00:00:00.000Z',
...overrides,
}
}
function quotaPackage(): QuotaStorePackage {
return {
id: 'pkg-1',
name: '100 GB',
description: 'Extra storage',
bytes: 107374182400,
amount: 999,
currency: 'usd' as const,
active: true,
sortOrder: 1,
cloudPackageId: 'cloud-pkg-1',
syncStatus: 'synced' as const,
syncError: null,
createdAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
}
}
function renderStorePage(queryClient: QueryClient) {
return render(
<QueryClientProvider client={queryClient}>
<StorePage />
</QueryClientProvider>,
)
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('StorePage', () => {
it('refreshes quota when a checkout grant is delivered', async () => {
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listQuotaStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal', role: 'owner', type: 'personal' }],
total: 1,
})
vi.mocked(listQuotaGrants).mockResolvedValue({
items: [grant()],
total: 1,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
renderStorePage(queryClient)
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] }))
})
it('hides self-service forms when the store is unavailable', async () => {
vi.mocked(listPurchasableQuotaPackages).mockRejectedValue(new Error('quota_store_disabled'))
vi.mocked(listQuotaStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal', role: 'owner', type: 'personal' }],
total: 1,
})
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStorePage(queryClient)
await waitFor(() => expect(view.getByText('store.unavailable')).toBeTruthy())
expect(view.queryByLabelText('store.storageCode')).toBeNull()
expect(view.queryByText('store.historyTitle')).toBeNull()
expect(listQuotaStoreTargets).not.toHaveBeenCalled()
expect(listQuotaGrants).not.toHaveBeenCalled()
})
it('refreshes quota and grants after redemption', async () => {
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listQuotaStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal', role: 'owner', type: 'personal' }],
total: 1,
})
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
vi.mocked(redeemQuotaCode).mockResolvedValue({ ok: true })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStorePage(queryClient)
await waitFor(() => expect(view.getByLabelText('store.storageCode')).toBeTruthy())
fireEvent.change(view.getByLabelText('store.storageCode'), { target: { value: 'STORE-CODE' } })
fireEvent.click(view.getByRole('button', { name: 'store.redeemButton' }))
await waitFor(() => expect(redeemQuotaCode).toHaveBeenCalledWith('STORE-CODE', 'org-1'))
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['quota-store', 'grants'] })
expect(toast.success).toHaveBeenCalledWith('store.redeemed')
})
it('closes the checkout window when checkout fails', async () => {
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [quotaPackage()], total: 1 })
vi.mocked(listQuotaStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal', role: 'owner', type: 'personal' }],
total: 1,
})
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
vi.mocked(createQuotaCheckout).mockRejectedValue(new Error('checkout failed'))
const checkoutWindow = { close: vi.fn(), opener: null, location: { href: '' } }
vi.spyOn(window, 'open').mockReturnValue(checkoutWindow as unknown as Window)
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStorePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'store.checkout' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'store.checkout' }))
await waitFor(() => expect(createQuotaCheckout).toHaveBeenCalledWith('pkg-1', 'org-1'))
expect(checkoutWindow.close).toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('checkout failed')
})
it('refreshes quota and grants after checkout starts', async () => {
vi.mocked(listPurchasableQuotaPackages).mockResolvedValue({ items: [quotaPackage()], total: 1 })
vi.mocked(listQuotaStoreTargets).mockResolvedValue({
items: [{ orgId: 'org-1', name: 'Personal', role: 'owner', type: 'personal' }],
total: 1,
})
vi.mocked(listQuotaGrants).mockResolvedValue({ items: [], total: 0 })
vi.mocked(createQuotaCheckout).mockResolvedValue({
checkoutUrl: 'https://cloud.example.test/checkout',
})
const checkoutWindow = { close: vi.fn(), opener: null, location: { href: '' } }
vi.spyOn(window, 'open').mockReturnValue(checkoutWindow as unknown as Window)
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
const view = renderStorePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'store.checkout' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'store.checkout' }))
await waitFor(() => expect(checkoutWindow.location.href).toBe('https://cloud.example.test/checkout'))
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['quota-store', 'grants'] })
})
})
+273
View File
@@ -0,0 +1,273 @@
import type { QuotaStorePackage, QuotaTarget } from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Gift, HardDrive, PlusCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
createQuotaCheckout,
listPurchasableQuotaPackages,
listQuotaGrants,
listQuotaStoreTargets,
redeemQuotaCode,
} from '@/lib/api'
import { formatSize } from '@/lib/format'
export const Route = createFileRoute('/_authenticated/store')({
component: StorePage,
})
export function StorePage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [targetOrgId, setTargetOrgId] = useState('')
const [code, setCode] = useState('')
const [checkoutRefreshActive, setCheckoutRefreshActive] = useState(false)
const storeQuery = useQuery({
queryKey: ['quota-store', 'packages'],
queryFn: listPurchasableQuotaPackages,
retry: false,
})
const targetsQuery = useQuery({
queryKey: ['quota-store', 'targets'],
queryFn: listQuotaStoreTargets,
enabled: storeQuery.isSuccess,
retry: false,
})
const grantsQuery = useQuery({
queryKey: ['quota-store', 'grants'],
queryFn: listQuotaGrants,
enabled: storeQuery.isSuccess,
retry: false,
})
const deliveredCheckoutCount =
grantsQuery.data?.items.filter((grant) => grant.source === 'stripe' && grant.active).length ?? 0
const targets = targetsQuery.data?.items ?? []
useEffect(() => {
if (!targetOrgId && targets[0]) setTargetOrgId(targets[0].orgId)
}, [targetOrgId, targets])
useEffect(() => {
if (deliveredCheckoutCount > 0) queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
}, [deliveredCheckoutCount, queryClient])
useEffect(() => {
if (!checkoutRefreshActive) return
const interval = window.setInterval(() => {
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
queryClient.invalidateQueries({ queryKey: ['quota-store', 'grants'] })
}, 5000)
const timeout = window.setTimeout(() => setCheckoutRefreshActive(false), 120000)
return () => {
window.clearInterval(interval)
window.clearTimeout(timeout)
}
}, [checkoutRefreshActive, queryClient])
const checkoutMutation = useMutation({
mutationFn: ({ packageId }: { packageId: string; checkoutWindow: Window | null }) =>
createQuotaCheckout(packageId, targetOrgId),
onSuccess: (result, variables) => {
if (variables.checkoutWindow) {
variables.checkoutWindow.location.href = result.checkoutUrl
} else {
window.location.assign(result.checkoutUrl)
}
setCheckoutRefreshActive(true)
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
queryClient.invalidateQueries({ queryKey: ['quota-store', 'grants'] })
},
onError: (err, variables) => {
variables.checkoutWindow?.close()
toast.error(err.message)
},
})
function startCheckout(packageId: string) {
const checkoutWindow = window.open('about:blank', '_blank')
if (checkoutWindow) checkoutWindow.opener = null
checkoutMutation.mutate({ packageId, checkoutWindow })
}
const redemptionMutation = useMutation({
mutationFn: () => redeemQuotaCode(code, targetOrgId),
onSuccess: () => {
setCode('')
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
queryClient.invalidateQueries({ queryKey: ['quota-store', 'grants'] })
toast.success(t('store.redeemed'))
},
onError: (err) => toast.error(err.message),
})
if (storeQuery.isLoading || (storeQuery.isSuccess && targetsQuery.isLoading)) {
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
}
if (storeQuery.isError) {
return (
<div className="max-w-3xl space-y-4">
<h2 className="text-2xl font-semibold tracking-tight">{t('store.title')}</h2>
<div className="rounded-md border border-dashed p-8 text-sm text-muted-foreground">
{t('store.unavailable')}
</div>
</div>
)
}
return (
<div className="max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">{t('store.title')}</h2>
<p className="text-sm text-muted-foreground">{t('store.subtitle')}</p>
</div>
<TargetSelect targets={targets} value={targetOrgId} onValueChange={setTargetOrgId} />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{(storeQuery.data?.items ?? []).map((pkg) => (
<PackageCard
key={pkg.id}
pkg={pkg}
disabled={!targetOrgId || checkoutMutation.isPending}
onCheckout={() => startCheckout(pkg.id)}
/>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<Card className="border-border/60">
<CardHeader>
<div className="flex items-center gap-2">
<Gift className="h-5 w-5 text-primary" />
<CardTitle>{t('store.redeemTitle')}</CardTitle>
</div>
<CardDescription>{t('store.redeemDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="storageCode">{t('store.storageCode')}</Label>
<Input id="storageCode" value={code} onChange={(e) => setCode(e.target.value)} />
</div>
<Button
className="w-full"
disabled={!code || !targetOrgId || redemptionMutation.isPending}
onClick={() => redemptionMutation.mutate()}
>
{t('store.redeemButton')}
</Button>
</CardContent>
</Card>
<Card className="border-border/60">
<CardHeader>
<CardTitle>{t('store.historyTitle')}</CardTitle>
<CardDescription>{t('store.historyDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(grantsQuery.data?.items ?? []).map((grant) => (
<div
key={grant.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border px-4 py-3"
>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{formatSize(grant.bytes)}</span>
<Badge variant="outline">{grant.source}</Badge>
<Badge variant={grant.active ? 'default' : 'secondary'}>
{grant.active ? 'active' : 'inactive'}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{grant.orgId}</p>
</div>
<span className="text-xs text-muted-foreground">{new Date(grant.createdAt).toLocaleString()}</span>
</div>
))}
{(grantsQuery.data?.items ?? []).length === 0 && (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
{t('store.noHistory')}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
function TargetSelect({
targets,
value,
onValueChange,
}: {
targets: QuotaTarget[]
value: string
onValueChange: (value: string) => void
}) {
const { t } = useTranslation()
return (
<div className="w-full space-y-2 sm:w-72">
<Label>{t('store.target')}</Label>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{targets.map((target) => (
<SelectItem key={target.orgId} value={target.orgId}>
{target.name} · {target.type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function PackageCard({
pkg,
disabled,
onCheckout,
}: {
pkg: QuotaStorePackage
disabled: boolean
onCheckout: () => void
}) {
const { t } = useTranslation()
return (
<Card className="border-border/60">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{pkg.name}</CardTitle>
<CardDescription className="mt-1">{pkg.description}</CardDescription>
</div>
<HardDrive className="h-5 w-5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-2xl font-semibold">{formatSize(pkg.bytes)}</p>
<p className="text-sm text-muted-foreground">{formatMoney(pkg.amount, pkg.currency)}</p>
</div>
<Button className="w-full" disabled={disabled} onClick={onCheckout}>
<PlusCircle className="mr-2 h-4 w-4" />
{t('store.checkout')}
</Button>
</CardContent>
</Card>
)
}
function formatMoney(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
}