mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: add quota store UI (#363)
Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "暂无存储授予记录。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'] })
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user