feat(quota): consolidate storage entitlements

This commit is contained in:
saltbo
2026-06-02 20:12:59 -04:00
parent 38adeb44f9
commit 7f85da222f
49 changed files with 4840 additions and 1373 deletions
+101
View File
@@ -0,0 +1,101 @@
ALTER TABLE `org_quota_entitlements` ADD `entitlement_type` text DEFAULT 'grant' NOT NULL;--> statement-breakpoint
UPDATE `org_quota_entitlements`
SET `entitlement_type` = 'plan'
WHERE `source_id` LIKE 'stripe_subscription:%'
AND `status` = 'active'
AND NOT EXISTS (
SELECT 1
FROM `org_quota_entitlements` AS newer_plan
WHERE newer_plan.`org_id` = `org_quota_entitlements`.`org_id`
AND newer_plan.`resource_type` = `org_quota_entitlements`.`resource_type`
AND newer_plan.`status` = 'active'
AND newer_plan.`source_id` LIKE 'stripe_subscription:%'
AND (
newer_plan.`bytes` > `org_quota_entitlements`.`bytes`
OR (
newer_plan.`bytes` = `org_quota_entitlements`.`bytes`
AND newer_plan.`starts_at` > `org_quota_entitlements`.`starts_at`
)
)
);--> statement-breakpoint
UPDATE `org_quota_entitlements`
SET `entitlement_type` = 'grant'
WHERE `entitlement_type` != 'plan';--> statement-breakpoint
INSERT INTO `org_quota_entitlements` (
`id`,
`org_id`,
`resource_type`,
`entitlement_type`,
`source`,
`source_id`,
`bytes`,
`starts_at`,
`expires_at`,
`status`,
`metadata`,
`created_at`,
`updated_at`
)
SELECT
'free-plan-storage-' || `org_quotas`.`org_id`,
`org_quotas`.`org_id`,
'storage',
'plan',
'free_plan',
'free_plan:' || `org_quotas`.`org_id`,
`org_quotas`.`quota`,
CAST(unixepoch('subsecond') * 1000 AS integer),
NULL,
'active',
json_object('packageName', 'Free', 'packageId', NULL, 'source', 'free_plan', 'migratedFrom', 'org_quotas.quota'),
CAST(unixepoch('subsecond') * 1000 AS integer),
CAST(unixepoch('subsecond') * 1000 AS integer)
FROM `org_quotas`
WHERE NOT EXISTS (
SELECT 1
FROM `org_quota_entitlements`
WHERE `org_quota_entitlements`.`org_id` = `org_quotas`.`org_id`
AND `org_quota_entitlements`.`resource_type` = 'storage'
AND `org_quota_entitlements`.`entitlement_type` = 'plan'
AND `org_quota_entitlements`.`status` = 'active'
);--> statement-breakpoint
INSERT INTO `org_quota_entitlements` (
`id`,
`org_id`,
`resource_type`,
`entitlement_type`,
`source`,
`source_id`,
`bytes`,
`starts_at`,
`expires_at`,
`status`,
`metadata`,
`created_at`,
`updated_at`
)
SELECT
'free-plan-traffic-' || `org_quotas`.`org_id`,
`org_quotas`.`org_id`,
'traffic',
'plan',
'free_plan',
'free_plan:' || `org_quotas`.`org_id`,
`org_quotas`.`traffic_quota`,
CAST(unixepoch('subsecond') * 1000 AS integer),
NULL,
'active',
json_object('packageName', 'Free', 'packageId', NULL, 'source', 'free_plan', 'migratedFrom', 'org_quotas.traffic_quota'),
CAST(unixepoch('subsecond') * 1000 AS integer),
CAST(unixepoch('subsecond') * 1000 AS integer)
FROM `org_quotas`
WHERE NOT EXISTS (
SELECT 1
FROM `org_quota_entitlements`
WHERE `org_quota_entitlements`.`org_id` = `org_quotas`.`org_id`
AND `org_quota_entitlements`.`resource_type` = 'traffic'
AND `org_quota_entitlements`.`entitlement_type` = 'plan'
AND `org_quota_entitlements`.`status` = 'active'
);--> statement-breakpoint
CREATE INDEX `org_quota_entitlements_org_type_idx` ON `org_quota_entitlements` (`org_id`,`resource_type`,`entitlement_type`,`status`);--> statement-breakpoint
CREATE UNIQUE INDEX `org_quota_entitlements_active_plan_uniq` ON `org_quota_entitlements` (`org_id`,`resource_type`,`entitlement_type`) WHERE status = 'active' AND entitlement_type = 'plan';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -225,6 +225,13 @@
"when": 1778557854518,
"tag": "0032_webdav-class2-state",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1780427619086,
"tag": "0033_quota-entitlement-types",
"breakpoints": true
}
]
}
+18 -1
View File
@@ -18,6 +18,21 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknow
})
}
async function expectPlanEntitlement(ctx: TestCtx, resourceType: 'storage' | 'traffic', bytes: number) {
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceType,
entitlementType: 'plan',
source: 'free_plan',
bytes,
status: 'active',
}),
]),
)
}
describe('registration gate — first user always allowed', () => {
it('first user can register when auth_signup_mode is closed', async () => {
const ctx = await createTestApp()
@@ -469,10 +484,12 @@ describe('createPersonalOrg — org name and quota edge cases', () => {
expect(res.status).toBe(200)
const quotas = await ctx.db.select().from(schema.orgQuotas)
expect(quotas).toHaveLength(1)
expect(quotas[0].quota).toBe(10485760)
expect(quotas[0].quota).toBe(0)
expect(quotas[0].trafficQuota).toBe(0)
expect(quotas[0].trafficUsed).toBe(0)
expect(quotas[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
await expectPlanEntitlement(ctx, 'storage', 10485760)
await expectPlanEntitlement(ctx, 'traffic', 0)
})
})
+55 -8
View File
@@ -14,7 +14,7 @@ import {
parseProviderConfig,
} from '../shared/oauth-providers'
import * as authSchema from './db/auth-schema'
import { orgQuotas, systemOptions } from './db/schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
import type { Database, Platform } from './platform/interface'
import { recordActivity } from './services/activity'
@@ -476,6 +476,7 @@ async function createPersonalOrg(
const displayName = user.name || user.username
const orgName = displayName ? `${displayName}'s Space` : 'Personal Space'
const quotaValues = await createOrgQuotaValues(db, orgId, now)
const entitlementValues = await createFreePlanEntitlementValues(db, orgId, now)
await executeWriteTransaction(db, [
db.insert(authSchema.organization).values({
@@ -493,28 +494,74 @@ async function createPersonalOrg(
createdAt: now,
}),
db.insert(orgQuotas).values(quotaValues),
...entitlementValues.map((value) => db.insert(orgQuotaEntitlements).values(value)),
])
return orgId
}
async function createOrgQuotaValues(db: Database, orgId: string, now: Date): Promise<typeof orgQuotas.$inferInsert> {
const defaultQuota = await getDefaultOrgQuota(db)
const defaultTrafficQuota = await getDefaultOrgTrafficQuota(db)
async function createOrgQuotaValues(_db: Database, orgId: string, now: Date): Promise<typeof orgQuotas.$inferInsert> {
return {
id: nanoid(),
orgId,
quota: defaultQuota,
quota: 0,
used: 0,
trafficQuota: defaultTrafficQuota,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: currentTrafficPeriod(now),
}
}
async function createOrgQuota(db: Database, orgId: string, now: Date): Promise<void> {
await db.insert(orgQuotas).values(await createOrgQuotaValues(db, orgId, now))
await executeWriteTransaction(db, [
db.insert(orgQuotas).values(await createOrgQuotaValues(db, orgId, now)),
...(await createFreePlanEntitlementValues(db, orgId, now)).map((value) =>
db.insert(orgQuotaEntitlements).values(value),
),
])
}
async function createFreePlanEntitlementValues(
db: Database,
orgId: string,
now: Date,
): Promise<(typeof orgQuotaEntitlements.$inferInsert)[]> {
const defaultQuota = await getDefaultOrgQuota(db)
const defaultTrafficQuota = await getDefaultOrgTrafficQuota(db)
return [
freePlanEntitlementValue(orgId, 'storage', defaultQuota, now, 'default_org_quota'),
freePlanEntitlementValue(orgId, 'traffic', defaultTrafficQuota, now, 'default_org_monthly_traffic_quota'),
]
}
function freePlanEntitlementValue(
orgId: string,
resourceType: 'storage' | 'traffic',
bytes: number,
now: Date,
settingKey: string,
): typeof orgQuotaEntitlements.$inferInsert {
return {
id: nanoid(),
orgId,
resourceType,
entitlementType: 'plan',
source: 'free_plan',
sourceId: `free_plan:${orgId}`,
bytes,
startsAt: now,
expiresAt: null,
status: 'active',
metadata: JSON.stringify({
packageName: 'Free',
packageId: null,
source: 'free_plan',
settingKey,
}),
createdAt: now,
updatedAt: now,
}
}
async function getDefaultOrgQuota(db: Database): Promise<number> {
+5
View File
@@ -111,6 +111,7 @@ export const orgQuotaEntitlements = sqliteTable(
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
resourceType: text('resource_type').notNull(),
entitlementType: text('entitlement_type').notNull().default('grant'),
source: text('source').notNull(),
sourceId: text('source_id').notNull(),
bytes: integer('bytes').notNull(),
@@ -123,6 +124,10 @@ export const orgQuotaEntitlements = sqliteTable(
},
(t) => [
index('org_quota_entitlements_org_resource_idx').on(t.orgId, t.resourceType, t.status),
index('org_quota_entitlements_org_type_idx').on(t.orgId, t.resourceType, t.entitlementType, t.status),
uniqueIndex('org_quota_entitlements_active_plan_uniq')
.on(t.orgId, t.resourceType, t.entitlementType)
.where(sql`status = 'active' AND entitlement_type = 'plan'`),
uniqueIndex('org_quota_entitlements_source_resource_uniq').on(t.source, t.sourceId, t.resourceType),
],
)
@@ -26,6 +26,24 @@ async function insertStorage(db: TestDb) {
`)
}
async function setTrafficPlanEntitlement(db: TestDb, orgId: string, bytes: number) {
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${`test-traffic-plan-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function insertImageHosting(db: TestDb, orgId: string, opts: { id: string; path: string; status?: string }) {
const now = Date.now()
await db.run(sql`
@@ -160,9 +178,10 @@ describe('imageHostingDomain middleware — custom domain redirect', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 256, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 2048)
const res = await app.request('/quota/image.png', {
headers: { host: 'img.quota.com' },
@@ -186,9 +205,10 @@ describe('imageHostingDomain middleware — custom domain redirect', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 512, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const res = await app.request('/quota/over.png', {
headers: { host: 'img.quota-over.com' },
@@ -210,9 +230,10 @@ describe('imageHostingDomain middleware — custom domain redirect', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 256, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 2048)
vi.mocked(S3Service.prototype.presignInline).mockRejectedValueOnce(new Error('sign failed'))
const res = await app.request('/quota/sign-fail.png', {
+12 -10
View File
@@ -546,25 +546,27 @@ describe('Storage audit events', () => {
// ─── Quota audit events ───────────────────────────────────────────────────────
describe('Quota audit events', () => {
it('records quota_update when admin updates a quota', async () => {
it('records quota_entitlement_grant when admin grants storage entitlement', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
const orgId = await getPersonalOrgId(db)
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'admin@example.com' LIMIT 1`)
const userId = users[0].id
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
const res = await app.request(`/api/admin/users/${userId}/entitlements`, {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 10737418240 }),
body: JSON.stringify({ resourceType: 'storage', bytes: 10737418240, note: 'audit grant' }),
})
expect(res.status).toBe(200)
expect(res.status).toBe(201)
const evt = await getLatestActivity(db, 'quota_update')
const evt = await getLatestActivity(db, 'quota_entitlement_grant')
expect(evt).toBeDefined()
expect(evt?.targetType).toBe('quota')
assertNoSecrets(evt?.metadata ?? null)
const meta = JSON.parse(evt?.metadata ?? '{}') as { quota: number; targetOrgId: string }
expect(meta.quota).toBe(10737418240)
expect(meta.targetOrgId).toBe(orgId)
const meta = JSON.parse(evt?.metadata ?? '{}') as { bytes: number; resourceType: string; targetUserId: string }
expect(meta.bytes).toBe(10737418240)
expect(meta.resourceType).toBe('storage')
expect(meta.targetUserId).toBe(userId)
})
})
+33 -6
View File
@@ -9,6 +9,26 @@ afterEach(() => {
vi.unstubAllGlobals()
})
async function expectPlanEntitlement(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
resourceType: 'storage' | 'traffic',
bytes: number,
) {
const rows = await db.select().from(schema.orgQuotaEntitlements).where(eq(schema.orgQuotaEntitlements.orgId, orgId))
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceType,
entitlementType: 'plan',
source: 'free_plan',
bytes,
status: 'active',
}),
]),
)
}
describe('Auth API', () => {
it('POST /api/auth/sign-up/email creates user', async () => {
const { app } = await createTestApp()
@@ -265,11 +285,13 @@ describe('Auth API', () => {
const rows = await db.select().from(schema.orgQuotas).where(eq(schema.orgQuotas.orgId, orgId))
expect(rows).toHaveLength(1)
expect(rows[0].quota).toBe(10485760)
expect(rows[0].quota).toBe(0)
expect(rows[0].used).toBe(0)
expect(rows[0].trafficQuota).toBe(0)
expect(rows[0].trafficUsed).toBe(0)
expect(rows[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
await expectPlanEntitlement(db, orgId, 'storage', 10485760)
await expectPlanEntitlement(db, orgId, 'traffic', 0)
})
it('signup with default quotas set creates an org_quotas row with storage and monthly traffic quotas', async () => {
@@ -292,11 +314,13 @@ describe('Auth API', () => {
const rows = await db.select().from(schema.orgQuotas).where(eq(schema.orgQuotas.orgId, orgId))
expect(rows).toHaveLength(1)
expect(rows[0].quota).toBe(1073741824)
expect(rows[0].quota).toBe(0)
expect(rows[0].used).toBe(0)
expect(rows[0].trafficQuota).toBe(2147483648)
expect(rows[0].trafficQuota).toBe(0)
expect(rows[0].trafficUsed).toBe(0)
expect(rows[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
await expectPlanEntitlement(db, orgId, 'storage', 1073741824)
await expectPlanEntitlement(db, orgId, 'traffic', 2147483648)
})
it('team org creation initializes storage and monthly traffic quotas from defaults', async () => {
@@ -320,11 +344,13 @@ describe('Auth API', () => {
const rows = await db.select().from(schema.orgQuotas).where(eq(schema.orgQuotas.orgId, org.id))
expect(rows).toHaveLength(1)
expect(rows[0].quota).toBe(1073741824)
expect(rows[0].quota).toBe(0)
expect(rows[0].used).toBe(0)
expect(rows[0].trafficQuota).toBe(2147483648)
expect(rows[0].trafficQuota).toBe(0)
expect(rows[0].trafficUsed).toBe(0)
expect(rows[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
await expectPlanEntitlement(db, org.id, 'storage', 1073741824)
await expectPlanEntitlement(db, org.id, 'traffic', 2147483648)
})
it('signup fails when stored default monthly traffic quota is invalid', async () => {
@@ -355,7 +381,8 @@ describe('Auth API', () => {
.where(eq(authSchema.organization.slug, `personal-${body.user.id}`))
const rows = await db.select().from(schema.orgQuotas).where(eq(schema.orgQuotas.orgId, orgs[0].id))
expect(rows).toHaveLength(1)
expect(rows[0].quota).toBe(10485760)
expect(rows[0].quota).toBe(0)
await expectPlanEntitlement(db, orgs[0].id, 'storage', 10485760)
})
it('sign-in with a malformed stored password hash returns a non-200 error response', async () => {
+13 -7
View File
@@ -1619,11 +1619,19 @@ describe('Quota Store API', () => {
const orgId = await getFirstOrgId(db)
const packageId = await seedPackage(db)
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'storage'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-active-plan', ${orgId}, 'storage', 'cloud_order', ${`stripe_subscription:sub_active:${orgId}`}, 4096, ${now}, NULL, 'active', '{"packageName":"Active Plan"}', ${now}, ${now})
('ent-active-plan', ${orgId}, 'storage', 'plan', 'cloud_order', ${`stripe_subscription:sub_active:${orgId}`}, 4096, ${now}, NULL, 'active', '{"packageName":"Active Plan"}', ${now}, ${now})
`)
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
@@ -2179,8 +2187,6 @@ describe('Quota Store API', () => {
const headers = await adminHeaders(app)
await seedSettings(app, headers)
const orgId = await getFirstOrgId(db)
const before = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgId}`)
const payload = JSON.stringify({
eventId: 'evt-1',
cloudOrderId: 'order-1',
@@ -2208,13 +2214,13 @@ describe('Quota Store API', () => {
const quotaRes = await app.request('/api/quotas/me', { headers })
const quota = (await quotaRes.json()) as { baseQuota: number; entitlementQuota: number; quota: number }
expect(quota.baseQuota).toBe(before[0].quota)
expect(quota.baseQuota).toBe(10485760)
expect(quota.entitlementQuota).toBe(4096)
expect(quota.quota).toBe(before[0].quota + 4096)
expect(quota.quota).toBe(10485760 + 4096)
const entitlement = await db.all<{ bytes: number; status: string; sourceId: string; expiresAt: number }>(sql`
SELECT bytes, status, source_id AS sourceId, expires_at AS expiresAt
FROM org_quota_entitlements
WHERE org_id = ${orgId} AND resource_type = 'storage'
WHERE org_id = ${orgId} AND resource_type = 'storage' AND source_id = 'order-1'
`)
expect(entitlement).toEqual([
{ bytes: 4096, status: 'active', sourceId: 'order-1', expiresAt: Date.parse('2099-06-01T00:00:00.000Z') },
+1 -1
View File
@@ -146,7 +146,7 @@ export const cloudStore = new Hono<Env>()
if (!price) return c.json({ error: 'package_price_missing' }, 400)
if (price.recurring) {
const quota = await getEffectiveQuota(db, targetOrgId)
if (quota.storagePlanName || quota.trafficPlanName) return c.json({ error: 'workspace_plan_exists' }, 409)
if (quota.currentPlan?.subscription) return c.json({ error: 'workspace_plan_exists' }, 409)
}
const order = await cloudRequest(c, async ({ client, storeId }) =>
unwrapCloudResponse(
+22 -5
View File
@@ -34,6 +34,24 @@ async function insertStorage(db: TestDb) {
`)
}
async function setStoragePlanEntitlement(db: TestDb, orgId: string, bytes: number) {
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'storage'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function insertImageHostingConfig(
db: TestDb,
orgId: string,
@@ -844,8 +862,8 @@ describe('POST /api/ihost/images (multipart)', () => {
const orgId = await getOrgId(db)
await insertImageHostingConfig(db, orgId)
// Set quota to 50 bytes so a 100-byte upload exceeds it
await db.run(sql`UPDATE org_quotas SET quota = 50 WHERE org_id = ${orgId}`)
// Set storage quota to 50 bytes so a 100-byte upload exceeds it
await setStoragePlanEntitlement(db, orgId, 50)
const formData = new FormData()
formData.append('file', new File([new Uint8Array(100)], 'big.png', { type: 'image/png' }))
@@ -957,9 +975,8 @@ describe('PATCH /api/ihost/images/:id (confirm)', () => {
const orgId = await getOrgId(db)
await insertImageHostingConfig(db, orgId)
// Sign-up creates an org_quotas row with the default quota.
// Lower it to 50 bytes so a 100-byte image exceeds it.
await db.run(sql`UPDATE org_quotas SET quota = 50 WHERE org_id = ${orgId}`)
// Lower storage quota to 50 bytes so a 100-byte image exceeds it.
await setStoragePlanEntitlement(db, orgId, 50)
const createRes = await app.request('/api/ihost/images/presign', {
method: 'POST',
@@ -78,6 +78,23 @@ async function setOrgQuota(
trafficPeriod: currentTrafficPeriod(),
})
}
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'storage'
AND entitlement_type = 'plan'
AND status = 'active'
`)
if (quota > 0) {
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, ${quota}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
}
async function addStorageEntitlement(
@@ -313,9 +330,24 @@ describe('GET /api/objects/:id — traffic quota enforcement', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 50, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}`}, 50, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
const res = await app.request('/api/objects/m-download-over', { headers })
expect(res.status).toBe(422)
+26 -223
View File
@@ -67,152 +67,6 @@ describe('Admin Quotas API', () => {
expect(rows[0].trafficPeriod).toBe(body.items[0].trafficPeriod)
})
it('PUT /api/admin/quotas/:orgId creates quota for org', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
// Find the admin's personal org
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1073741824, trafficQuota: 2147483648 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.orgId).toBe(orgId)
expect(body.quota).toBe(1073741824)
expect(body.trafficQuota).toBe(2147483648)
// Verify in DB
const quotas = await db.all<{ quota: number; trafficQuota: number }>(
sql`SELECT quota, traffic_quota AS trafficQuota FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(quotas[0].quota).toBe(1073741824)
expect(quotas[0].trafficQuota).toBe(2147483648)
})
it('PUT /api/admin/quotas/:orgId creates missing quota with default monthly traffic quota', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
await db.run(sql`DELETE FROM org_quotas WHERE org_id = ${orgId}`)
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 2048 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.quota).toBe(2048)
expect(body.trafficQuota).toBe(0)
expect(body.trafficUsed).toBe(0)
expect(body.trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
})
it('PUT /api/admin/quotas/:orgId updates existing quota', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
// Create initial quota
await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1000 }),
})
// Update it
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 2000 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.quota).toBe(2000)
expect(body.trafficQuota).toBe(0)
expect(body.trafficUsed).toBe(0)
expect(body.trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
})
it('PUT /api/admin/quotas/:orgId rejects negative quota', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/quotas/some-org', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: -100 }),
})
expect(res.status).toBe(400)
})
it('PUT /api/admin/quotas/:orgId rejects zero quota', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/quotas/some-org', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 0 }),
})
expect(res.status).toBe(400)
})
it('PUT /api/admin/quotas/:orgId rejects decimal quota', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/quotas/some-org', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1.5 }),
})
expect(res.status).toBe(400)
})
it('PUT /api/admin/quotas/:orgId rejects negative traffic quota', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/quotas/some-org', {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1000, trafficQuota: -1 }),
})
expect(res.status).toBe(400)
})
it('PUT /api/admin/quotas/:orgId works without Pro license', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
const res = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1000 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.orgId).toBe(orgId)
expect(body.quota).toBe(1000)
})
it('GET /api/admin/quotas lists quotas with org info', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
@@ -222,19 +76,12 @@ describe('Admin Quotas API', () => {
)
const orgId = orgs[0].id
// Create a quota
await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 5000 }),
})
const res = await app.request('/api/admin/quotas', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<Record<string, unknown>>; total: number }
expect(body.items).toHaveLength(1)
expect(body.items[0].orgId).toBe(orgId)
expect(body.items[0].quota).toBe(5000)
expect(body.items[0].quota).toBe(10485760)
expect(body.items[0].trafficQuota).toBe(0)
expect(body.items[0].trafficUsed).toBe(0)
expect(body.items[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
@@ -250,14 +97,17 @@ describe('Admin Quotas API', () => {
)
const orgId = orgs[0].id
const now = Date.now()
await db.run(sql`UPDATE org_quotas SET quota = 5000, traffic_quota = 1000 WHERE org_id = ${orgId}`)
await db.run(sql`UPDATE org_quotas SET quota = 0, traffic_quota = 0 WHERE org_id = ${orgId}`)
await db.run(sql`DELETE FROM org_quota_entitlements WHERE org_id = ${orgId}`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-admin-storage', ${orgId}, 'storage', 'test', 'admin-storage', 3000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-traffic', ${orgId}, 'traffic', 'test', 'admin-traffic', 2000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-revoked', ${orgId}, 'storage', 'test', 'admin-revoked', 9000, ${now}, NULL, 'revoked', NULL, ${now}, ${now})
('ent-admin-storage-plan', ${orgId}, 'storage', 'plan', 'test', 'admin-storage-plan', 5000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-storage', ${orgId}, 'storage', 'grant', 'test', 'admin-storage', 3000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-traffic-plan', ${orgId}, 'traffic', 'plan', 'test', 'admin-traffic-plan', 1000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-traffic', ${orgId}, 'traffic', 'grant', 'test', 'admin-traffic', 2000, ${now}, NULL, 'active', NULL, ${now}, ${now}),
('ent-admin-revoked', ${orgId}, 'storage', 'grant', 'test', 'admin-revoked', 9000, ${now}, NULL, 'revoked', NULL, ${now}, ${now})
`)
const res = await app.request('/api/admin/quotas', { headers })
@@ -282,16 +132,17 @@ describe('Admin Quotas API', () => {
)
const orgId = orgs[0].id
const now = Date.now()
await db.run(sql`UPDATE org_quotas SET quota = 1000, traffic_quota = 2000 WHERE org_id = ${orgId}`)
await db.run(sql`UPDATE org_quotas SET quota = 0, traffic_quota = 0 WHERE org_id = ${orgId}`)
await db.run(sql`DELETE FROM org_quota_entitlements WHERE org_id = ${orgId}`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-admin-plan-storage', ${orgId}, 'storage', 'test', ${`stripe_subscription:sub_storage:${orgId}`}, 3000, ${now}, NULL, 'active', '{"packageName":"Team Plan"}', ${now}, ${now}),
('ent-admin-plan-storage-old', ${orgId}, 'storage', 'test', ${`stripe_subscription:sub_storage_old:${orgId}`}, 2500, ${now}, NULL, 'active', '{"packageName":"Old Team Plan"}', ${now}, ${now}),
('ent-admin-extra-storage', ${orgId}, 'storage', 'test', 'storage-pack', 700, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-admin-plan-traffic', ${orgId}, 'traffic', 'test', ${`stripe_subscription:sub_traffic:${orgId}`}, 4000, ${now}, NULL, 'active', '{"packageName":"Team Plan"}', ${now}, ${now}),
('ent-admin-extra-traffic', ${orgId}, 'traffic', 'test', 'traffic-pack', 900, ${now}, NULL, 'active', '{"packageName":"Traffic Boost"}', ${now}, ${now})
('ent-admin-plan-storage', ${orgId}, 'storage', 'plan', 'test', ${`stripe_subscription:sub_storage:${orgId}`}, 3000, ${now}, NULL, 'active', '{"packageName":"Team Plan"}', ${now}, ${now}),
('ent-admin-plan-storage-old', ${orgId}, 'storage', 'plan', 'test', ${`stripe_subscription:sub_storage_old:${orgId}`}, 2500, ${now}, NULL, 'revoked', '{"packageName":"Old Team Plan"}', ${now}, ${now}),
('ent-admin-extra-storage', ${orgId}, 'storage', 'grant', 'test', 'storage-pack', 700, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-admin-plan-traffic', ${orgId}, 'traffic', 'plan', 'test', ${`stripe_subscription:sub_traffic:${orgId}`}, 4000, ${now}, NULL, 'active', '{"packageName":"Team Plan"}', ${now}, ${now}),
('ent-admin-extra-traffic', ${orgId}, 'traffic', 'grant', 'test', 'traffic-pack', 900, ${now}, NULL, 'active', '{"packageName":"Traffic Boost"}', ${now}, ${now})
`)
const res = await app.request('/api/admin/quotas', { headers })
@@ -351,33 +202,6 @@ describe('User Quotas API — /api/quotas', () => {
expect(res.status).toBe(404)
})
it('GET /api/quotas/me returns quota after admin sets it', async () => {
const { app, db } = await createTestApp()
const adminH = await adminHeaders(app)
// Find admin's org
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
// Set quota as admin
await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...adminH, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 10000 }),
})
// Check as user (admin is also a user)
const res = await app.request('/api/quotas/me', { headers: adminH })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.quota).toBe(10000)
expect(body.used).toBe(0)
expect(body.trafficQuota).toBe(0)
expect(body.trafficUsed).toBe(0)
})
it('GET /api/quotas/me returns base quota plus active entitlements and labels', async () => {
const { app, db } = await createTestApp()
const adminH = await adminHeaders(app)
@@ -386,13 +210,16 @@ describe('User Quotas API — /api/quotas', () => {
)
const orgId = orgs[0].id
const now = Date.now()
await db.run(sql`UPDATE org_quotas SET quota = 1000, traffic_quota = 2000 WHERE org_id = ${orgId}`)
await db.run(sql`UPDATE org_quotas SET quota = 0, traffic_quota = 0 WHERE org_id = ${orgId}`)
await db.run(sql`DELETE FROM org_quota_entitlements WHERE org_id = ${orgId}`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-user-storage', ${orgId}, 'storage', 'test', 'user-storage', 4000, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-user-traffic', ${orgId}, 'traffic', 'test', 'user-traffic', 6000, ${now}, NULL, 'active', '{"packageName":"Traffic Boost"}', ${now}, ${now})
('ent-user-storage-plan', ${orgId}, 'storage', 'plan', 'test', 'user-storage-plan', 1000, ${now}, NULL, 'active', '{"packageName":"Free"}', ${now}, ${now}),
('ent-user-storage', ${orgId}, 'storage', 'grant', 'test', 'user-storage', 4000, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-user-traffic-plan', ${orgId}, 'traffic', 'plan', 'test', 'user-traffic-plan', 2000, ${now}, NULL, 'active', '{"packageName":"Free"}', ${now}, ${now}),
('ent-user-traffic', ${orgId}, 'traffic', 'grant', 'test', 'user-traffic', 6000, ${now}, NULL, 'active', '{"packageName":"Traffic Boost"}', ${now}, ${now})
`)
const res = await app.request('/api/quotas/me', { headers: adminH })
@@ -406,34 +233,10 @@ describe('User Quotas API — /api/quotas', () => {
baseTrafficQuota: 2000,
entitlementTrafficQuota: 6000,
trafficQuota: 8000,
storagePlanName: null,
storagePlanName: 'Free',
storageExtraNames: ['Storage Pack'],
trafficPlanName: null,
trafficPlanName: 'Free',
trafficExtraNames: ['Traffic Boost'],
})
})
it('admin quota updates current org quota without historical grant aggregation', async () => {
const { app, db } = await createTestApp()
const adminH = await adminHeaders(app)
const orgs = await db.all<{ id: string }>(
sql`SELECT o.id FROM organization o WHERE o.metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
const orgId = orgs[0].id
const putRes = await app.request(`/api/admin/quotas/${orgId}`, {
method: 'PUT',
headers: { ...adminH, 'Content-Type': 'application/json' },
body: JSON.stringify({ quota: 1000 }),
})
expect(putRes.status).toBe(200)
const updated = (await putRes.json()) as Record<string, unknown>
expect(updated.baseQuota).toBe(1000)
expect(updated.quota).toBe(1000)
const res = await app.request('/api/quotas/me', { headers: adminH })
const body = (await res.json()) as Record<string, unknown>
expect(body.baseQuota).toBe(1000)
expect(body.quota).toBe(1000)
})
})
+28 -77
View File
@@ -1,93 +1,44 @@
import { zValidator } from '@hono/zod-validator'
import { eq, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import { organization } from '../db/auth-schema'
import { orgQuotas } from '../db/schema'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { recordActivity } from '../services/activity'
import { currentTrafficPeriod, getEffectiveQuota } from '../services/effective-quota'
import { findPersonalOrg } from '../services/org'
const updateQuotaSchema = z.object({
quota: z.number().int().positive(),
trafficQuota: z.number().int().nonnegative().optional(),
})
const adminQuotas = new Hono<Env>().use(requireAdmin).get('/', async (c) => {
const db = c.get('platform').db
const period = currentTrafficPeriod()
const now = new Date()
const adminQuotas = new Hono<Env>()
.use(requireAdmin)
.get('/', async (c) => {
const db = c.get('platform').db
const period = currentTrafficPeriod()
const now = new Date()
await db
.update(orgQuotas)
.set({ trafficUsed: 0, trafficPeriod: period })
.where(sql`${orgQuotas.trafficPeriod} != ${period}`)
await db
.update(orgQuotas)
.set({ trafficUsed: 0, trafficPeriod: period })
.where(sql`${orgQuotas.trafficPeriod} != ${period}`)
const rows = await db
.select({
id: orgQuotas.id,
orgId: orgQuotas.orgId,
orgName: organization.name,
orgMetadata: organization.metadata,
})
.from(orgQuotas)
.innerJoin(organization, eq(organization.id, orgQuotas.orgId))
.orderBy(organization.name)
const items = await Promise.all(
rows.map(async (r) => ({
id: r.id,
...(await getEffectiveQuota(db, r.orgId, now)),
orgName: r.orgName,
orgType: parseOrgType(r.orgMetadata),
})),
)
return c.json({ items, total: items.length })
})
.put('/:orgId', zValidator('json', updateQuotaSchema), async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const adminOrgId = c.get('orgId')!
const targetOrgId = c.req.param('orgId')
const { quota, trafficQuota } = c.req.valid('json')
const existing = await db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, targetOrgId))
if (existing.length > 0) {
await db
.update(orgQuotas)
.set(trafficQuota == null ? { quota } : { quota, trafficQuota })
.where(eq(orgQuotas.orgId, targetOrgId))
} else {
await db.insert(orgQuotas).values({
id: nanoid(),
orgId: targetOrgId,
quota,
used: 0,
trafficQuota: trafficQuota ?? 0,
trafficUsed: 0,
trafficPeriod: currentTrafficPeriod(),
})
}
await recordActivity(db, {
orgId: adminOrgId,
userId,
action: 'quota_update',
targetType: 'quota',
targetId: targetOrgId,
targetName: targetOrgId,
metadata: { quota, trafficQuota, targetOrgId },
const rows = await db
.select({
id: orgQuotas.id,
orgId: orgQuotas.orgId,
orgName: organization.name,
orgMetadata: organization.metadata,
})
.from(orgQuotas)
.innerJoin(organization, eq(organization.id, orgQuotas.orgId))
.orderBy(organization.name)
return c.json(await getEffectiveQuota(db, targetOrgId))
})
const items = await Promise.all(
rows.map(async (r) => ({
id: r.id,
...(await getEffectiveQuota(db, r.orgId, now)),
orgName: r.orgName,
orgType: parseOrgType(r.orgMetadata),
})),
)
return c.json({ items, total: items.length })
})
const userQuotas = new Hono<Env>().use(requireAuth).get('/me', async (c) => {
const db = c.get('platform').db
+28 -3
View File
@@ -25,6 +25,28 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
`)
}
async function setTrafficPlanEntitlement(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
bytes: number,
) {
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${`test-traffic-plan-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
const rows = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`,
@@ -127,9 +149,10 @@ describe('GET /r/:token (ds_ direct shares)', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 512, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const share = await createShare(db, { matterId: 'ds-quota', orgId, creatorId, kind: 'direct', downloadLimit: 1 })
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
@@ -319,9 +342,10 @@ describe('GET /r/:token (ih_ image hosting)', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 1024, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 1024)
const first = await app.request('/r/ih_quotarepeat', { redirect: 'manual' })
expect(first.status).toBe(302)
@@ -495,9 +519,10 @@ describe('GET /r/:token — two-org isolation', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 512, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const res = await app.request('/r/ih_quotatest', { redirect: 'manual' })
expect(res.status).toBe(422)
+24 -1
View File
@@ -23,6 +23,28 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
`)
}
async function setTrafficPlanEntitlement(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
bytes: number,
) {
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${`test-traffic-plan-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
const rows = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`,
@@ -397,9 +419,10 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => {
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 512, traffic_used = 0, traffic_period = ${trafficPeriod}
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const share = await createShare(db, { matterId: 'dl-traffic', orgId, creatorId, kind: 'landing', downloadLimit: 1 })
const rootRef = await fetchRootRef(app, share.token)
+20 -1
View File
@@ -30,6 +30,24 @@ async function insertStorage(db: TestDb) {
`)
}
async function setStoragePlanEntitlement(db: TestDb, orgId: string, bytes: number) {
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'storage'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function insertFile(
db: TestDb,
orgId: string,
@@ -634,7 +652,8 @@ describe('POST /api/shares/:token/objects', () => {
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-quota', name: 'big-file.txt' })
await db.run(sql`UPDATE org_quotas SET quota = 1, used = 1 WHERE org_id = ${orgId}`)
await db.run(sql`UPDATE org_quotas SET used = 1 WHERE org_id = ${orgId}`)
await setStoragePlanEntitlement(db, orgId, 1)
const createRes = await createShare(app, headers, { matterId: 'sv-quota', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
+62 -63
View File
@@ -69,7 +69,14 @@ describe('Admin Users API', () => {
sql`SELECT id FROM organization WHERE slug = ${`personal-${userId}`}`,
)
await db.run(sql`UPDATE org_quotas SET quota = 123456, used = 789 WHERE org_id = ${personalOrgs[0].id}`)
await db.run(sql`UPDATE org_quotas SET quota = 0, used = 789 WHERE org_id = ${personalOrgs[0].id}`)
await db.run(sql`DELETE FROM org_quota_entitlements WHERE org_id = ${personalOrgs[0].id}`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-user-list-storage', ${personalOrgs[0].id}, 'storage', 'plan', 'test', 'user-list-storage', 123456, ${Date.now()}, NULL, 'active', NULL, ${Date.now()}, ${Date.now()})
`)
await db.run(
sql`INSERT INTO organization (id, name, slug, metadata) VALUES ('team-org', 'Team Org', 'team-org', '{}')`,
)
@@ -86,7 +93,7 @@ describe('Admin Users API', () => {
email: 'quota-list@example.com',
orgId: personalOrgs[0].id,
quotaUsed: 789,
quotaDefault: 123456,
quotaDefault: 0,
quotaTotal: 123456,
})
})
@@ -103,15 +110,16 @@ describe('Admin Users API', () => {
const orgId = personalOrgs[0].id
const now = Date.now()
await db.run(sql`UPDATE org_quotas SET quota = 10000, used = 900 WHERE org_id = ${orgId}`)
await db.run(sql`UPDATE org_quotas SET quota = 0, used = 900 WHERE org_id = ${orgId}`)
await db.run(sql`DELETE FROM org_quota_entitlements WHERE org_id = ${orgId}`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
('ent-user-plan-storage', ${orgId}, 'storage', 'test', ${`stripe_subscription:sub_storage:${orgId}`}, 5000, ${now}, NULL, 'active', '{"packageName":"Small Plan"}', ${now}, ${now}),
('ent-user-plan-storage-old', ${orgId}, 'storage', 'test', ${`stripe_subscription:sub_storage_old:${orgId}`}, 4000, ${now}, NULL, 'active', '{"packageName":"Old Plan"}', ${now}, ${now}),
('ent-user-extra-storage', ${orgId}, 'storage', 'test', 'storage-pack', 1000, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-user-expired-storage', ${orgId}, 'storage', 'test', 'expired-storage-pack', 9000, ${now}, ${now - 1}, 'active', '{"packageName":"Expired Pack"}', ${now}, ${now})
('ent-user-plan-storage', ${orgId}, 'storage', 'plan', 'test', ${`stripe_subscription:sub_storage:${orgId}`}, 5000, ${now}, NULL, 'active', '{"packageName":"Small Plan"}', ${now}, ${now}),
('ent-user-plan-storage-old', ${orgId}, 'storage', 'plan', 'test', ${`stripe_subscription:sub_storage_old:${orgId}`}, 4000, ${now}, NULL, 'revoked', '{"packageName":"Old Plan"}', ${now}, ${now}),
('ent-user-extra-storage', ${orgId}, 'storage', 'grant', 'test', 'storage-pack', 1000, ${now}, NULL, 'active', '{"packageName":"Storage Pack"}', ${now}, ${now}),
('ent-user-expired-storage', ${orgId}, 'storage', 'grant', 'test', 'expired-storage-pack', 9000, ${now}, ${now - 1}, 'active', '{"packageName":"Expired Pack"}', ${now}, ${now})
`)
const res = await app.request('/api/admin/users?search=quota-plan@example.com', { headers })
@@ -122,7 +130,7 @@ describe('Admin Users API', () => {
email: 'quota-plan@example.com',
orgId,
quotaUsed: 900,
quotaDefault: 10000,
quotaDefault: 0,
quotaTotal: 6000,
})
})
@@ -283,54 +291,52 @@ describe('Admin Users API', () => {
expect(enabled.every((row) => row.banned === 0)).toBe(true)
})
it('PATCH /api/admin/users/batch sets quota for personal orgs', async () => {
it('POST /api/admin/users/:id/entitlements grants storage entitlement for a personal org', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await signUpUser(app, 'quota1@example.com')
await signUpUser(app, 'quota2@example.com')
const users = await db.all<{ id: string }>(
sql`SELECT id FROM user WHERE email IN ('quota1@example.com', 'quota2@example.com') ORDER BY email`,
)
const ids = users.map((row) => row.id)
const res = await app.request('/api/admin/users/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_quota', ids, quota: 123456 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { updated: number; orgIds: string[]; quota: number }
expect(body.updated).toBe(2)
expect(body.quota).toBe(123456)
const quotas = await db.all<{ quota: number }>(
sql`SELECT quota FROM org_quotas WHERE org_id IN (${body.orgIds[0]}, ${body.orgIds[1]})`,
)
expect(quotas.map((row) => row.quota)).toEqual([123456, 123456])
})
it('PATCH /api/admin/users/batch creates missing personal quota rows', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await signUpUser(app, 'quota-missing@example.com')
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'quota-missing@example.com'`)
await signUpUser(app, 'grant-storage@example.com')
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'grant-storage@example.com'`)
const userId = users[0].id
const orgs = await db.all<{ id: string }>(sql`SELECT id FROM organization WHERE slug = ${`personal-${userId}`}`)
await db.run(sql`DELETE FROM org_quotas WHERE org_id = ${orgs[0].id}`)
const res = await app.request('/api/admin/users/batch', {
method: 'PATCH',
const res = await app.request(`/api/admin/users/${userId}/entitlements`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 654321 }),
body: JSON.stringify({ resourceType: 'storage', bytes: 123456, note: 'launch bonus' }),
})
expect(res.status).toBe(200)
const quotas = await db.all<{ quota: number }>(sql`SELECT quota FROM org_quotas WHERE org_id = ${orgs[0].id}`)
expect(quotas).toHaveLength(1)
expect(quotas[0].quota).toBe(654321)
expect(res.status).toBe(201)
const body = (await res.json()) as { orgId: string; entitlement: Record<string, unknown> }
expect(body.orgId).toBe(orgs[0].id)
expect(body.entitlement).toMatchObject({
orgId: orgs[0].id,
resourceType: 'storage',
entitlementType: 'grant',
source: 'admin_grant',
bytes: 123456,
status: 'active',
})
const entitlements = await db.all<{ bytes: number; entitlementType: string; source: string }>(
sql`SELECT bytes, entitlement_type AS entitlementType, source FROM org_quota_entitlements WHERE org_id = ${orgs[0].id} AND source = 'admin_grant'`,
)
expect(entitlements).toEqual([{ bytes: 123456, entitlementType: 'grant', source: 'admin_grant' }])
})
it('PATCH /api/admin/users/batch fails when selected user has no personal org', async () => {
it('POST /api/admin/users/:id/entitlements rejects traffic grants', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const user = (await signUpUser(app, 'traffic-grant@example.com')) as { user: { id: string } }
const res = await app.request(`/api/admin/users/${user.user.id}/entitlements`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceType: 'traffic', bytes: 123456 }),
})
expect(res.status).toBe(400)
})
it('POST /api/admin/users/:id/entitlements fails when selected user has no personal org', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await signUpUser(app, 'no-personal-org@example.com')
@@ -338,14 +344,14 @@ describe('Admin Users API', () => {
const userId = users[0].id
await db.run(sql`DELETE FROM member WHERE user_id = ${userId}`)
const res = await app.request('/api/admin/users/batch', {
method: 'PATCH',
const res = await app.request(`/api/admin/users/${userId}/entitlements`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 123456 }),
body: JSON.stringify({ resourceType: 'storage', bytes: 123456 }),
})
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: `Personal organization not found for user(s): ${userId}` })
expect(await res.json()).toEqual({ error: `Personal organization not found for user: ${userId}` })
})
it('DELETE /api/admin/users/batch deletes selected users', async () => {
@@ -381,14 +387,6 @@ describe('Admin Users API', () => {
expect(patch.status).toBe(404)
expect(await patch.json()).toEqual({ error: 'User not found: missing-user' })
const quota = await app.request('/api/admin/users/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_quota', ids: ['missing-user'], quota: 123456 }),
})
expect(quota.status).toBe(404)
expect(await quota.json()).toEqual({ error: 'User not found: missing-user' })
const del = await app.request('/api/admin/users/batch', {
method: 'DELETE',
headers: { ...headers, 'Content-Type': 'application/json' },
@@ -398,13 +396,14 @@ describe('Admin Users API', () => {
expect(await del.json()).toEqual({ error: 'User not found: missing-user' })
})
it('PATCH /api/admin/users/batch rejects non-positive quota values', async () => {
it('POST /api/admin/users/:id/entitlements rejects non-positive bytes', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/users/batch', {
method: 'PATCH',
const user = (await signUpUser(app, 'zero-grant@example.com')) as { user: { id: string } }
const res = await app.request(`/api/admin/users/${user.user.id}/entitlements`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_quota', ids: ['some-user'], quota: 0 }),
body: JSON.stringify({ resourceType: 'storage', bytes: 0 }),
})
expect(res.status).toBe(400)
})
+58 -21
View File
@@ -7,9 +7,11 @@ import { recordActivity } from '../services/activity'
import {
deleteUser,
deleteUsers,
getUser,
grantUserPersonalEntitlement,
listUserPersonalEntitlements,
listUsers,
setUserStatus,
setUsersPersonalQuota,
setUsersStatus,
} from '../services/user'
@@ -26,13 +28,15 @@ const batchPatchSchema = z.discriminatedUnion('action', [
action: z.enum(['disable', 'enable']),
ids: z.array(z.string().min(1)).min(1),
}),
z.object({
action: z.literal('set_quota'),
ids: z.array(z.string().min(1)).min(1),
quota: z.number().int().positive(),
}),
])
const grantEntitlementSchema = z.object({
resourceType: z.literal('storage'),
bytes: z.number().int().positive(),
expiresAt: z.string().datetime().nullable().optional(),
note: z.string().max(500).nullable().optional(),
})
const app = new Hono<Env>()
.use(requireAdmin)
.get('/', async (c) => {
@@ -44,27 +48,19 @@ const app = new Hono<Env>()
const result = await listUsers(db, page, pageSize, search)
return c.json(result)
})
.get('/:id', async (c) => {
const db = c.get('platform').db
const userId = c.req.param('id')
const result = await getUser(db, userId)
if ('error' in result) return c.json({ error: result.error }, result.status)
return c.json(result)
})
.patch('/batch', zValidator('json', batchPatchSchema), async (c) => {
const db = c.get('platform').db
const adminUserId = c.get('userId')!
const orgId = c.get('orgId')!
const body = c.req.valid('json')
if (body.action === 'set_quota') {
const result = await setUsersPersonalQuota(db, body.ids, body.quota)
if ('error' in result) return c.json({ error: result.error }, result.status)
await recordActivity(db, {
orgId,
userId: adminUserId,
action: 'quota_update',
targetType: 'quota',
targetName: 'batch',
metadata: result,
})
return c.json(result)
}
const status = body.action === 'disable' ? 'disabled' : 'active'
const result = await setUsersStatus(db, body.ids, status)
if ('error' in result) return c.json({ error: result.error }, result.status)
@@ -79,6 +75,47 @@ const app = new Hono<Env>()
})
return c.json({ ...result, status })
})
.get('/:id/entitlements', async (c) => {
const db = c.get('platform').db
const userId = c.req.param('id')
const result = await listUserPersonalEntitlements(db, userId)
if ('error' in result) return c.json({ error: result.error }, result.status)
return c.json(result)
})
.post('/:id/entitlements', zValidator('json', grantEntitlementSchema), async (c) => {
const db = c.get('platform').db
const adminUserId = c.get('userId')!
const adminOrgId = c.get('orgId')!
const targetUserId = c.req.param('id')
const body = c.req.valid('json')
const result = await grantUserPersonalEntitlement(db, {
adminUserId,
targetUserId,
resourceType: body.resourceType,
bytes: body.bytes,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
note: body.note,
})
if ('error' in result) return c.json({ error: result.error }, result.status)
await recordActivity(db, {
orgId: adminOrgId,
userId: adminUserId,
action: 'quota_entitlement_grant',
targetType: 'quota',
targetId: result.orgId,
targetName: targetUserId,
metadata: {
targetUserId,
entitlementId: result.entitlement.id,
resourceType: result.entitlement.resourceType,
bytes: result.entitlement.bytes,
expiresAt: result.entitlement.expiresAt?.toISOString() ?? null,
},
})
return c.json(result, 201)
})
.delete('/batch', zValidator('json', userIdsSchema), async (c) => {
const db = c.get('platform').db
const adminUserId = c.get('userId')!
+14 -2
View File
@@ -14,6 +14,16 @@ const ORG_ID = 'archive-org'
const USER_ID = 'archive-user'
const STORAGE_ID = 'archive-storage'
async function seedStoragePlanEntitlement(db: TestDb, orgId: string, bytes: number, id: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${id}, ${orgId}, 'storage', 'plan', 'test', ${`${id}:${orgId}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
class MemoryS3 {
objects = new Map<string, Uint8Array>()
putKeys: string[] = []
@@ -457,8 +467,9 @@ describe('archive processing', () => {
await seedStorage(db)
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES ('archive-quota', ${ORG_ID}, 4, 0, 0, 0, '1970-01')
VALUES ('archive-quota', ${ORG_ID}, 0, 0, 0, 0, '1970-01')
`)
await seedStoragePlanEntitlement(db, ORG_ID, 4, 'archive-quota-plan')
await seedMatter(db, { id: 'quota-zip', name: 'quota.zip', object: 'source/quota.zip', size: 200 })
const s3 = new MemoryS3()
@@ -482,8 +493,9 @@ describe('archive processing', () => {
await seedStorage(db)
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES ('archive-compress-quota', ${ORG_ID}, 4, 0, 0, 0, '1970-01')
VALUES ('archive-compress-quota', ${ORG_ID}, 0, 0, 0, 0, '1970-01')
`)
await seedStoragePlanEntitlement(db, ORG_ID, 4, 'archive-compress-quota-plan')
await seedMatter(db, { id: 'file-a', name: 'a.txt', object: 'objects/a.txt', size: 5 })
const s3 = new MemoryS3()
+27 -2
View File
@@ -187,7 +187,8 @@ async function requireTargetQuota(db: Database, orgId: string): Promise<void> {
}
function insertQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
return quotaEntitlementValues(event, now).map((value) =>
return quotaEntitlementValues(event, now).flatMap((value) => [
...revokeExistingPlanQueries(db, value, now),
db
.insert(orgQuotaEntitlements)
.values(value)
@@ -195,7 +196,29 @@ function insertQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChang
target: [orgQuotaEntitlements.source, orgQuotaEntitlements.sourceId, orgQuotaEntitlements.resourceType],
set: quotaEntitlementIncreaseValues(value, now),
}),
)
])
}
function revokeExistingPlanQueries(
db: Database,
value: typeof orgQuotaEntitlements.$inferInsert,
now: Date,
): AtomicQuery[] {
if (value.entitlementType !== 'plan') return []
return [
db
.update(orgQuotaEntitlements)
.set({ status: 'revoked', updatedAt: now })
.where(
and(
eq(orgQuotaEntitlements.orgId, value.orgId),
eq(orgQuotaEntitlements.resourceType, value.resourceType),
eq(orgQuotaEntitlements.entitlementType, 'plan'),
eq(orgQuotaEntitlements.status, 'active'),
sql`${orgQuotaEntitlements.sourceId} != ${value.sourceId}`,
),
),
]
}
function revokeQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
@@ -268,6 +291,7 @@ function quotaEntitlementIncreaseValues(value: typeof orgQuotaEntitlements.$infe
END` as unknown as number)
return {
bytes,
entitlementType: value.entitlementType,
status: 'active',
expiresAt: value.expiresAt,
metadata: value.metadata,
@@ -295,6 +319,7 @@ function quotaEntitlementValue(
id: nanoid(),
orgId: event.targetOrgId,
resourceType,
entitlementType: isSubscriptionSourceId(event.cloudOrderId) ? 'plan' : 'grant',
source: 'cloud_order',
sourceId: event.cloudOrderId,
bytes,
+30 -10
View File
@@ -25,6 +25,12 @@ describe('effective quota', () => {
trafficUsed: 500,
trafficPeriod: '2026-05',
})
await db
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'storage', 'free-storage-plan', 1000, 'active', new Date('2026-05-06T00:00:00Z'), 'Free'),
entitlement(orgId, 'traffic', 'free-traffic-plan', 2000, 'active', new Date('2026-05-06T00:00:00Z'), 'Free'),
])
await expect(getEffectiveQuota(db, orgId, new Date('2026-05-06T00:00:00Z'))).resolves.toMatchObject({
orgId,
@@ -51,6 +57,8 @@ describe('effective quota', () => {
trafficPeriod: '2026-05',
})
await db.insert(orgQuotaEntitlements).values([
entitlement(orgId, 'storage', 'free-storage-plan', 1000, 'active', now, 'Free'),
entitlement(orgId, 'traffic', 'free-traffic-plan', 2000, 'active', now, 'Free'),
entitlement(orgId, 'storage', 'active-storage', 300, 'active', now),
entitlement(orgId, 'traffic', 'active-traffic', 700, 'active', now),
entitlement(orgId, 'storage', 'revoked-storage', 900, 'revoked', now),
@@ -87,10 +95,10 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'storage', `stripe_subscription:sub_storage:${orgId}`, 3000, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 2500, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 2500, 'revoked', now),
entitlement(orgId, 'storage', 'order-storage-pack', 500, 'active', now),
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic:${orgId}`, 4000, 'active', now),
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic_legacy:${orgId}`, 3500, 'active', now),
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic_legacy:${orgId}`, 3500, 'revoked', now),
entitlement(orgId, 'traffic', 'order-traffic-pack', 700, 'active', now),
])
@@ -254,12 +262,12 @@ describe('effective quota', () => {
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
baseQuota: 1000,
baseQuota: 0,
entitlementQuota: 0,
quota: 1000,
baseTrafficQuota: 2000,
quota: 0,
baseTrafficQuota: 0,
entitlementTrafficQuota: 0,
trafficQuota: 2000,
trafficQuota: 0,
})
})
@@ -298,6 +306,9 @@ describe('effective quota', () => {
trafficUsed: 400,
trafficPeriod: '2026-05',
})
await db
.insert(orgQuotaEntitlements)
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
await expect(hasTrafficQuotaForBytes(db, orgId, 600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
@@ -320,7 +331,12 @@ describe('effective quota', () => {
trafficUsed: 900,
trafficPeriod: '2026-05',
})
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'traffic', 'traffic-overage', 500, 'active', now))
await db
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'),
entitlement(orgId, 'traffic', 'traffic-overage', 500, 'active', now),
])
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 201, now)).resolves.toBe(false)
@@ -346,7 +362,7 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic:${orgId}`, 2000, 'active', now),
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic_legacy:${orgId}`, 1500, 'active', now),
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic_legacy:${orgId}`, 1500, 'revoked', now),
entitlement(orgId, 'traffic', 'traffic-pack', 500, 'active', now),
])
@@ -471,6 +487,9 @@ describe('effective quota', () => {
trafficUsed: 900,
trafficPeriod: '2026-04',
})
await db
.insert(orgQuotaEntitlements)
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 401, now)).resolves.toBe(false)
@@ -555,7 +574,7 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'storage', `stripe_subscription:sub_storage:${orgId}`, 2000, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 1500, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 1500, 'revoked', now),
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
])
@@ -581,7 +600,7 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values([
entitlement(orgId, 'storage', `stripe_subscription:sub_storage:${orgId}`, 2000, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 1500, 'active', now),
entitlement(orgId, 'storage', `stripe_subscription:sub_storage_legacy:${orgId}`, 1500, 'revoked', now),
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
])
@@ -613,6 +632,7 @@ function entitlement(
id: nanoid(),
orgId,
resourceType,
entitlementType: sourceId.startsWith('stripe_subscription:') || sourceId.endsWith('-plan') ? 'plan' : 'grant',
source: 'test',
sourceId,
bytes,
+28 -58
View File
@@ -57,21 +57,17 @@ export async function getEffectiveQuota(db: Database, orgId: string, now = new D
.limit(1)
const quotaRow = quotaRows[0]
const defaultQuota = quotaRow?.baseQuota ?? 0
const storagePlan = await activePlanEntitlement(db, orgId, 'storage', now)
const planQuota = storagePlan?.bytes ?? 0
const baseQuota = planQuota > 0 ? planQuota : defaultQuota
const entitlementQuota = await activeExtraEntitlementBytes(db, orgId, 'storage', now)
const storageExtraNames = await activeExtraEntitlementNames(db, orgId, 'storage', now)
const entitlementTrafficQuota = await activeExtraEntitlementBytes(db, orgId, 'traffic', now)
const trafficExtraNames = await activeExtraEntitlementNames(db, orgId, 'traffic', now)
const trafficUsed = quotaRow && quotaRow.trafficPeriod === period ? quotaRow.trafficUsed : 0
const trafficPeriod = quotaRow?.trafficPeriod === period ? quotaRow.trafficPeriod : period
const defaultTrafficQuota = quotaRow?.trafficQuota ?? 0
const trafficPlan = await activePlanEntitlement(db, orgId, 'traffic', now)
const planTrafficQuota = trafficPlan?.bytes ?? 0
const baseTrafficQuota = planTrafficQuota > 0 ? planTrafficQuota : defaultTrafficQuota
const currentPlan = buildCurrentPlan(storagePlan, trafficPlan)
const baseQuota = storagePlan?.bytes ?? 0
const baseTrafficQuota = trafficPlan?.bytes ?? 0
return {
orgId,
baseQuota,
@@ -130,9 +126,13 @@ export async function consumeTrafficIfQuotaAllows(
const overageAllowedSql = trafficOverageAllowed ? sql`1 = 1` : sql`1 = 0`
if (quotaRows[0].trafficPeriod !== period) {
const planBytes = activePlanEntitlementBytesSql(orgId, 'traffic', now)
const extraBytes = activeExtraEntitlementBytesSql(orgId, 'traffic', now)
const limitBytes = effectiveQuotaLimitSql(orgQuotas.trafficQuota, planBytes, extraBytes)
const limitBytes = activeEntitlementBytesSql({
aggregate: sql`SUM(${orgQuotaEntitlements.bytes})`,
orgId,
resourceType: 'traffic',
now,
sourceCondition: sql`1 = 1`,
})
const updated = await db
.update(orgQuotas)
.set({ trafficUsed: bytes, trafficPeriod: period })
@@ -140,7 +140,7 @@ export async function consumeTrafficIfQuotaAllows(
sql`${orgQuotas.orgId} = ${orgId}
AND ${orgQuotas.trafficPeriod} != ${period}
AND (
(${orgQuotas.trafficQuota} = 0 AND ${planBytes} = 0 AND ${extraBytes} = 0)
(${limitBytes} = 0)
OR ${overageAllowedSql}
OR ${bytes} <= ${limitBytes}
)`,
@@ -149,9 +149,13 @@ export async function consumeTrafficIfQuotaAllows(
if (updated.length > 0) return true
}
const planBytes = activePlanEntitlementBytesSql(orgId, 'traffic', now)
const extraBytes = activeExtraEntitlementBytesSql(orgId, 'traffic', now)
const limitBytes = effectiveQuotaLimitSql(orgQuotas.trafficQuota, planBytes, extraBytes)
const limitBytes = activeEntitlementBytesSql({
aggregate: sql`SUM(${orgQuotaEntitlements.bytes})`,
orgId,
resourceType: 'traffic',
now,
sourceCondition: sql`1 = 1`,
})
const updated = await db
.update(orgQuotas)
.set({ trafficUsed: sql`${orgQuotas.trafficUsed} + ${bytes}` })
@@ -159,7 +163,7 @@ export async function consumeTrafficIfQuotaAllows(
sql`${orgQuotas.orgId} = ${orgId}
AND ${orgQuotas.trafficPeriod} = ${period}
AND (
(${orgQuotas.trafficQuota} = 0 AND ${planBytes} = 0 AND ${extraBytes} = 0)
(${limitBytes} = 0)
OR ${overageAllowedSql}
OR ${orgQuotas.trafficUsed} + ${bytes} <= ${limitBytes}
)`,
@@ -196,16 +200,20 @@ export async function incrementUsageIfEffectiveQuotaAllows(
if (teamQuotaEnabled) {
const rows = await db.select({ id: orgQuotas.id }).from(orgQuotas).where(eq(orgQuotas.orgId, orgId)).limit(1)
if (rows.length > 0) {
const planBytes = activePlanEntitlementBytesSql(orgId, 'storage', now)
const extraBytes = activeExtraEntitlementBytesSql(orgId, 'storage', now)
const limitBytes = effectiveQuotaLimitSql(orgQuotas.quota, planBytes, extraBytes)
const limitBytes = activeEntitlementBytesSql({
aggregate: sql`SUM(${orgQuotaEntitlements.bytes})`,
orgId,
resourceType: 'storage',
now,
sourceCondition: sql`1 = 1`,
})
const updated = await db
.update(orgQuotas)
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
.where(
sql`${orgQuotas.orgId} = ${orgId}
AND (
(${orgQuotas.quota} = 0 AND ${planBytes} = 0 AND ${extraBytes} = 0)
(${limitBytes} = 0)
OR ${orgQuotas.used} + ${bytes} <= ${limitBytes}
)`,
)
@@ -288,21 +296,11 @@ async function activeExtraEntitlementNames(
}
function activePlanEntitlementWhere(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
return activeEntitlementWhere(
orgId,
resourceType,
now,
sql`${orgQuotaEntitlements.sourceId} LIKE 'stripe_subscription:%'`,
)
return activeEntitlementWhere(orgId, resourceType, now, sql`${orgQuotaEntitlements.entitlementType} = 'plan'`)
}
function activeExtraEntitlementWhere(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
return activeEntitlementWhere(
orgId,
resourceType,
now,
sql`${orgQuotaEntitlements.sourceId} NOT LIKE 'stripe_subscription:%'`,
)
return activeEntitlementWhere(orgId, resourceType, now, sql`${orgQuotaEntitlements.entitlementType} != 'plan'`)
}
function activeEntitlementWhere(
@@ -322,26 +320,6 @@ function activeEntitlementWhere(
)
}
function activePlanEntitlementBytesSql(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
return activeEntitlementBytesSql({
aggregate: sql`MAX(${orgQuotaEntitlements.bytes})`,
orgId,
resourceType,
now,
sourceCondition: sql`${orgQuotaEntitlements.sourceId} LIKE 'stripe_subscription:%'`,
})
}
function activeExtraEntitlementBytesSql(orgId: string, resourceType: 'storage' | 'traffic', now: Date) {
return activeEntitlementBytesSql({
aggregate: sql`SUM(${orgQuotaEntitlements.bytes})`,
orgId,
resourceType,
now,
sourceCondition: sql`${orgQuotaEntitlements.sourceId} NOT LIKE 'stripe_subscription:%'`,
})
}
function activeEntitlementBytesSql({
aggregate,
orgId,
@@ -368,14 +346,6 @@ function activeEntitlementBytesSql({
)`
}
function effectiveQuotaLimitSql(
defaultQuota: typeof orgQuotas.quota | typeof orgQuotas.trafficQuota,
planBytes: ReturnType<typeof sql>,
extraBytes: ReturnType<typeof sql>,
) {
return sql`CASE WHEN ${planBytes} > 0 THEN ${planBytes} ELSE ${defaultQuota} END + ${extraBytes}`
}
interface PlanEntitlement {
sourceId: string
bytes: number
@@ -475,7 +475,13 @@ describe('confirmUpload — quota-then-replace atomicity', () => {
const quotaId = nanoid()
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES (${quotaId}, ${orgId}, 100, 100, 0, 0, '2026-05')
VALUES (${quotaId}, ${orgId}, 0, 100, 0, 0, '2026-05')
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, 100, ${Date.now()}, NULL, 'active', '{"packageName":"Test Plan"}', ${Date.now()}, ${Date.now()})
`)
// Incumbent active file that 'replace' would trash
+20 -2
View File
@@ -1,7 +1,7 @@
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { orgQuotas } from '../db/schema.js'
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
import { createTestApp } from '../test/setup.js'
import { getEffectiveQuota, hasQuotaForBytes } from './effective-quota.js'
import { confirmUpload, incrementUsageIfAllowed, listTrashedRoots, updateMatter } from './matter.js'
@@ -23,12 +23,30 @@ async function insertOrgQuota(db: TestDb, orgId: string, quota: number, used = 0
await db.insert(orgQuotas).values({
id: nanoid(),
orgId,
quota,
quota: 0,
used,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: '2026-05',
})
if (quota > 0) {
const now = new Date()
await db.insert(orgQuotaEntitlements).values({
id: nanoid(),
orgId,
resourceType: 'storage',
entitlementType: 'plan',
source: 'test',
sourceId: `test-storage-plan:${orgId}:${nanoid()}`,
bytes: quota,
startsAt: new Date(),
expiresAt: null,
status: 'active',
metadata: JSON.stringify({ packageName: 'Test Plan' }),
createdAt: now,
updatedAt: now,
})
}
}
async function insertDraftFile(
@@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DirType } from '../../shared/constants'
import { activityEvents, matters, orgQuotas, shares } from '../db/schema'
import { activityEvents, matters, orgQuotaEntitlements, orgQuotas, shares } from '../db/schema'
import { S3Service } from '../services/s3.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
import { computeSourceBytes, isQuotaSufficient, saveShareToDrive } from './save-to-drive.js'
@@ -61,12 +61,30 @@ async function seedOrgQuota(db: TestDb, orgId: string, quota: number, used = 0)
await db.insert(orgQuotas).values({
id: nanoid(),
orgId,
quota,
quota: 0,
used,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: '2026-05',
})
if (quota > 0) {
const now = new Date()
await db.insert(orgQuotaEntitlements).values({
id: nanoid(),
orgId,
resourceType: 'storage',
entitlementType: 'plan',
source: 'test',
sourceId: `test-storage-plan:${orgId}:${nanoid()}`,
bytes: quota,
startsAt: new Date(),
expiresAt: null,
status: 'active',
metadata: JSON.stringify({ packageName: 'Test Plan' }),
createdAt: now,
updatedAt: now,
})
}
}
async function getShare(db: TestDb, shareId: string) {
@@ -633,12 +651,28 @@ describe('POST /api/shares/:token/objects', () => {
await db.insert(orgQuotas).values({
id: nanoid(),
orgId: quotaOrgId,
quota: 100,
quota: 0,
used: 0,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: '2026-05',
})
const now = new Date()
await db.insert(orgQuotaEntitlements).values({
id: nanoid(),
orgId: quotaOrgId,
resourceType: 'storage',
entitlementType: 'plan',
source: 'test',
sourceId: `test-storage-plan:${quotaOrgId}:${nanoid()}`,
bytes: 100,
startsAt: new Date(),
expiresAt: null,
status: 'active',
metadata: JSON.stringify({ packageName: 'Test Plan' }),
createdAt: now,
updatedAt: now,
})
// The share from setup() has a matter with size 512 — well above the 100-byte quota
const res = await app.request(`/api/shares/${share.token}/objects`, {
+109 -75
View File
@@ -3,8 +3,6 @@ import { nanoid } from 'nanoid'
import { member, organization, user } from '../db/auth-schema'
import { orgQuotaEntitlements, orgQuotas } from '../db/schema'
import type { Database } from '../platform/interface'
import { type AtomicQuery, executeWriteTransaction } from './db-transaction'
import { currentTrafficPeriod } from './effective-quota'
export interface UserWithOrg {
id: string
@@ -22,6 +20,22 @@ export interface UserWithOrg {
quotaTotal: number
}
export interface QuotaEntitlementItem {
id: string
orgId: string
resourceType: string
entitlementType: string
source: string
sourceId: string
bytes: number
startsAt: Date
expiresAt: Date | null
status: string
metadata: string | null
createdAt: Date
updatedAt: Date
}
export interface UserOperationFailure {
error: string
status: 404
@@ -62,8 +76,8 @@ export async function listUsers(
orgId: organization.id,
orgName: organization.name,
quotaUsed: orgQuotas.used,
quotaDefault: orgQuotas.quota,
quotaTotal: sql<number>`${effectiveStoragePlanBytesSql(now)} + ${activeExtraStorageBytesSql(now)}`,
quotaDefault: sql<number>`0`,
quotaTotal: activeStorageEntitlementBytesSql(now),
})
.from(user)
.leftJoin(organization, eq(organization.slug, sql`'personal-' || ${user.id}`))
@@ -82,30 +96,43 @@ export async function listUsers(
return { items, total }
}
function effectiveStoragePlanBytesSql(now: Date) {
return sql`CASE
WHEN ${activePlanStorageBytesSql(now)} > 0 THEN ${activePlanStorageBytesSql(now)}
ELSE COALESCE(${orgQuotas.quota}, 0)
END`
export async function getUser(db: Database, userId: string): Promise<UserWithOrg | UserOperationFailure> {
const now = new Date()
const rows = await db
.select({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
image: user.image,
role: user.role,
banned: user.banned,
createdAt: user.createdAt,
orgId: organization.id,
orgName: organization.name,
quotaUsed: orgQuotas.used,
quotaDefault: sql<number>`0`,
quotaTotal: activeStorageEntitlementBytesSql(now),
})
.from(user)
.leftJoin(organization, eq(organization.slug, sql`'personal-' || ${user.id}`))
.leftJoin(orgQuotas, eq(orgQuotas.orgId, organization.id))
.where(eq(user.id, userId))
const row = rows[0]
if (!row) return { error: `User not found: ${userId}`, status: 404 }
return {
...row,
username: row.username ?? '',
quotaUsed: row.quotaUsed ?? 0,
quotaDefault: row.quotaDefault ?? 0,
quotaTotal: row.quotaTotal ?? 0,
}
}
function activePlanStorageBytesSql(now: Date) {
function activeStorageEntitlementBytesSql(now: Date) {
const timestamp = now.getTime()
return sql`(
SELECT COALESCE(MAX(${orgQuotaEntitlements.bytes}), 0)
FROM ${orgQuotaEntitlements}
WHERE ${orgQuotaEntitlements.orgId} = ${organization.id}
AND ${orgQuotaEntitlements.resourceType} = 'storage'
AND ${orgQuotaEntitlements.status} = 'active'
AND ${orgQuotaEntitlements.startsAt} <= ${timestamp}
AND (${orgQuotaEntitlements.expiresAt} IS NULL OR ${orgQuotaEntitlements.expiresAt} > ${timestamp})
AND ${orgQuotaEntitlements.sourceId} LIKE 'stripe_subscription:%'
)`
}
function activeExtraStorageBytesSql(now: Date) {
const timestamp = now.getTime()
return sql`(
return sql<number>`(
SELECT COALESCE(SUM(${orgQuotaEntitlements.bytes}), 0)
FROM ${orgQuotaEntitlements}
WHERE ${orgQuotaEntitlements.orgId} = ${organization.id}
@@ -113,7 +140,6 @@ function activeExtraStorageBytesSql(now: Date) {
AND ${orgQuotaEntitlements.status} = 'active'
AND ${orgQuotaEntitlements.startsAt} <= ${timestamp}
AND (${orgQuotaEntitlements.expiresAt} IS NULL OR ${orgQuotaEntitlements.expiresAt} > ${timestamp})
AND ${orgQuotaEntitlements.sourceId} NOT LIKE 'stripe_subscription:%'
)`
}
@@ -162,64 +188,72 @@ export async function deleteUsers(
return { deleted: existingIds.length, ids: existingIds }
}
export async function setUsersPersonalQuota(
export async function listUserPersonalEntitlements(
db: Database,
userIds: string[],
quota: number,
): Promise<{ updated: number; userIds: string[]; orgIds: string[]; quota: number } | UserOperationFailure> {
const existingIds = await requireUsers(db, userIds)
if ('error' in existingIds) return existingIds
userId: string,
): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> {
const org = await findUserPersonalOrg(db, userId)
if ('error' in org) return org
const items = await db
.select()
.from(orgQuotaEntitlements)
.where(eq(orgQuotaEntitlements.orgId, org.orgId))
.orderBy(desc(orgQuotaEntitlements.createdAt))
return { orgId: org.orgId, items }
}
export async function grantUserPersonalEntitlement(
db: Database,
input: {
adminUserId: string
targetUserId: string
resourceType: 'storage'
bytes: number
expiresAt?: Date | null
note?: string | null
},
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
const org = await findUserPersonalOrg(db, input.targetUserId)
if ('error' in org) return org
const now = new Date()
const entitlement = {
id: nanoid(),
orgId: org.orgId,
resourceType: input.resourceType,
entitlementType: 'grant',
source: 'admin_grant',
sourceId: `admin_grant:${nanoid()}`,
bytes: input.bytes,
startsAt: now,
expiresAt: input.expiresAt ?? null,
status: 'active',
metadata: JSON.stringify({
note: input.note ?? null,
grantedBy: input.adminUserId,
targetUserId: input.targetUserId,
}),
createdAt: now,
updatedAt: now,
} satisfies typeof orgQuotaEntitlements.$inferInsert
const rows = await db.insert(orgQuotaEntitlements).values(entitlement).returning()
return { orgId: org.orgId, entitlement: rows[0] }
}
async function findUserPersonalOrg(db: Database, userId: string): Promise<{ orgId: string } | UserOperationFailure> {
const existingIds = await requireUsers(db, [userId])
if ('error' in existingIds) return existingIds
const rows = await db
.select({ userId: user.id, orgId: organization.id })
.select({ orgId: organization.id })
.from(user)
.innerJoin(member, eq(member.userId, user.id))
.innerJoin(
organization,
and(eq(organization.id, member.organizationId), eq(organization.slug, sql`'personal-' || ${user.id}`)),
)
.where(inArray(user.id, existingIds))
if (rows.length !== existingIds.length) {
const found = new Set(rows.map((row) => row.userId))
const missing = existingIds.filter((id) => !found.has(id))
return { error: `Personal organization not found for user(s): ${missing.join(', ')}`, status: 404 }
}
const orgIds = rows.map((row) => row.orgId)
const existingQuotaRows = await db
.select({ orgId: orgQuotas.orgId })
.from(orgQuotas)
.where(inArray(orgQuotas.orgId, orgIds))
const existingOrgIds = new Set(existingQuotaRows.map((row) => row.orgId))
const nowMissing = orgIds.filter((orgId) => !existingOrgIds.has(orgId))
const queries: AtomicQuery[] = []
if (existingOrgIds.size > 0) {
queries.push(
db
.update(orgQuotas)
.set({ quota })
.where(inArray(orgQuotas.orgId, [...existingOrgIds])),
)
}
for (const orgId of nowMissing) {
queries.push(
db.insert(orgQuotas).values({
id: nanoid(),
orgId,
quota,
used: 0,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: currentTrafficPeriod(),
}),
)
}
await executeWriteTransaction(db, queries)
return { updated: rows.length, userIds: existingIds, orgIds, quota }
.where(eq(user.id, userId))
const orgId = rows[0]?.orgId
if (!orgId) return { error: `Personal organization not found for user: ${userId}`, status: 404 }
return { orgId }
}
async function requireUsers(db: Database, userIds: string[]): Promise<string[] | UserOperationFailure> {
+6
View File
@@ -183,6 +183,7 @@ const APP_SCHEMA_SQL = `
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
resource_type TEXT NOT NULL,
entitlement_type TEXT NOT NULL DEFAULT 'grant',
source TEXT NOT NULL,
source_id TEXT NOT NULL,
bytes INTEGER NOT NULL,
@@ -195,6 +196,11 @@ const APP_SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_resource_idx
ON org_quota_entitlements(org_id, resource_type, status);
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_type_idx
ON org_quota_entitlements(org_id, resource_type, entitlement_type, status);
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_active_plan_uniq
ON org_quota_entitlements(org_id, resource_type, entitlement_type)
WHERE status = 'active' AND entitlement_type = 'plan';
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_source_resource_uniq
ON org_quota_entitlements(source, source_id, resource_type);
CREATE TABLE IF NOT EXISTS webhook_events (
+16
View File
@@ -59,6 +59,22 @@ export interface OrgQuota {
currentPlan?: CurrentStoragePlan | null
}
export interface OrgQuotaEntitlement {
id: string
orgId: string
resourceType: 'storage' | 'traffic' | string
entitlementType: 'plan' | 'campaign' | 'grant' | string
source: string
sourceId: string
bytes: number
startsAt: string
expiresAt: string | null
status: string
metadata: string | null
createdAt: string
updatedAt: string
}
export interface CurrentStoragePlan {
sourceId: string
packageId: string | null
@@ -0,0 +1,146 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { grantUserEntitlement } from '@/lib/api'
interface GrantUserEntitlementDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
user: { id: string; name: string } | null
}
const QUOTA_UNITS = ['MB', 'GB', 'TB'] as const
type QuotaUnit = (typeof QUOTA_UNITS)[number]
const UNIT_BYTES: Record<QuotaUnit, number> = {
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
}
export function GrantUserEntitlementDialog({ open, onOpenChange, user }: GrantUserEntitlementDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [amount, setAmount] = useState('')
const [unit, setUnit] = useState<QuotaUnit>('GB')
const [expiresAt, setExpiresAt] = useState('')
const [note, setNote] = useState('')
useEffect(() => {
if (!open) {
setAmount('')
setUnit('GB')
setExpiresAt('')
setNote('')
}
}, [open])
const grantMutation = useMutation({
mutationFn: () => {
if (!user) throw new Error('user_required')
const value = Number(amount)
if (!Number.isFinite(value) || value <= 0) throw new Error(t('admin.users.positiveQuotaRequired'))
return grantUserEntitlement(user.id, {
resourceType: 'storage',
bytes: Math.round(value * UNIT_BYTES[unit]),
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
note: note.trim() || null,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'users', user?.id] })
queryClient.invalidateQueries({ queryKey: ['admin', 'users', user?.id, 'entitlements'] })
toast.success(t('admin.users.entitlementGranted'))
onOpenChange(false)
},
onError: (err) => {
toast.error(err.message)
},
})
if (!user) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('admin.users.grantEntitlementFor', { name: user.name })}</DialogTitle>
<DialogDescription>{t('admin.users.grantEntitlementDescription')}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
grantMutation.mutate()
}}
>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_96px]">
<div className="space-y-2">
<Label htmlFor="entitlement-amount">{t('admin.users.entitlementAmount')}</Label>
<Input
id="entitlement-amount"
type="number"
min="0.1"
step="0.1"
value={amount}
onChange={(event) => setAmount(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>{t('admin.users.quotaUnit')}</Label>
<Select value={unit} onValueChange={(value) => setUnit(value as QuotaUnit)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUOTA_UNITS.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="entitlement-expires">{t('admin.users.entitlementExpires')}</Label>
<Input
id="entitlement-expires"
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="entitlement-note">{t('admin.users.entitlementNote')}</Label>
<Textarea id="entitlement-note" value={note} onChange={(event) => setNote(event.target.value)} rows={3} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={grantMutation.isPending}>
{grantMutation.isPending ? t('common.loading') : t('admin.users.grantEntitlement')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,151 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import type React from 'react'
import { toast } from 'sonner'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { UserQuotaDialog } from './user-quota-dialog'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: { name?: string; used?: string }) =>
values?.name ? `${key}:${values.name}` : values?.used ? `${key}:${values.used}` : key,
}),
}))
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('@/lib/api', () => ({
updateQuota: vi.fn(),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, type, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => (
<button type={type ?? 'button'} {...props}>
{children}
</button>
),
}))
vi.mock('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}))
vi.mock('@/components/ui/label', () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
}))
vi.mock('@/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode
value: string
onValueChange: (value: string) => void
}) => (
<select aria-label="admin.users.quotaUnit" value={value} onChange={(e) => onValueChange(e.target.value)}>
{children}
</select>
),
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
<option value={value}>{children}</option>
),
SelectTrigger: () => null,
SelectValue: () => null,
}))
const user = {
name: 'Test User',
orgId: 'org-1',
quotaUsed: 512,
quotaDefault: 2 * 1024 * 1024 * 1024,
}
function renderDialog(props: Partial<React.ComponentProps<typeof UserQuotaDialog>> = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<UserQuotaDialog open onOpenChange={vi.fn()} user={user} {...props} />
</QueryClientProvider>,
)
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('UserQuotaDialog', () => {
it('prefills the editable default quota instead of effective quota', () => {
const view = renderDialog({
user: {
...user,
quotaDefault: 2 * 1024 * 1024 * 1024,
},
})
expect((view.getByLabelText('admin.users.quotaLabel') as HTMLInputElement).value).toBe('2')
})
it('shows the generic success toast by default after saving', async () => {
const onSave = vi.fn().mockResolvedValue({ orgId: user.orgId, quota: 3 })
const onOpenChange = vi.fn()
const view = renderDialog({ onOpenChange, onSave })
fireEvent.change(view.getByLabelText('admin.users.quotaLabel'), { target: { value: '3' } })
fireEvent.submit(view.getByRole('button', { name: 'common.save' }).closest('form')!)
await waitFor(() => expect(onSave).toHaveBeenCalledWith(3 * 1024 * 1024 * 1024))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(toast.success).toHaveBeenCalledWith('admin.users.quotaUpdated')
})
it('does not show the generic success toast when disabled', async () => {
const onSave = vi.fn().mockResolvedValue({ updated: 2 })
const onOpenChange = vi.fn()
const view = renderDialog({ onOpenChange, onSave, showSuccessToast: false })
fireEvent.change(view.getByLabelText('admin.users.quotaLabel'), { target: { value: '4' } })
fireEvent.submit(view.getByRole('button', { name: 'common.save' }).closest('form')!)
await waitFor(() => expect(onSave).toHaveBeenCalledWith(4 * 1024 * 1024 * 1024))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(toast.success).not.toHaveBeenCalled()
})
it('saves quota using the selected unit', async () => {
const onSave = vi.fn().mockResolvedValue({ orgId: user.orgId, quota: 512 * 1024 * 1024 })
const view = renderDialog({ onSave })
fireEvent.change(view.getByLabelText('admin.users.quotaUnit'), { target: { value: 'MB' } })
fireEvent.change(view.getByLabelText('admin.users.quotaLabel'), { target: { value: '512' } })
fireEvent.submit(view.getByRole('button', { name: 'common.save' }).closest('form')!)
await waitFor(() => expect(onSave).toHaveBeenCalledWith(512 * 1024 * 1024))
})
})
-147
View File
@@ -1,147 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { updateQuota } from '@/lib/api'
interface UserQuotaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
user: { name: string; orgId: string; quotaUsed: number; quotaDefault: number } | null
onSave?: (quota: number) => Promise<unknown>
showSuccessToast?: boolean
}
const QUOTA_UNITS = ['MB', 'GB', 'TB'] as const
type QuotaUnit = (typeof QUOTA_UNITS)[number]
const UNIT_BYTES: Record<QuotaUnit, number> = {
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
}
export function UserQuotaDialog({ open, onOpenChange, user, onSave, showSuccessToast = true }: UserQuotaDialogProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [quotaValue, setQuotaValue] = useState('')
const [quotaUnit, setQuotaUnit] = useState<QuotaUnit>('GB')
useEffect(() => {
if (open && user) {
setQuotaUnit('GB')
setQuotaValue(user.quotaDefault > 0 ? formatQuotaValue(user.quotaDefault, 'GB') : '')
}
}, [open, user])
const mutation = useMutation({
mutationFn: ({ orgId, quota }: { orgId: string; quota: number }) => onSave?.(quota) ?? updateQuota(orgId, quota),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'quotas'] })
onOpenChange(false)
if (showSuccessToast) toast.success(t('admin.users.quotaUpdated'))
},
onError: (err) => {
toast.error(err.message)
},
})
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) setQuotaValue('')
onOpenChange(nextOpen)
}
function handleUnitChange(nextUnit: QuotaUnit) {
const value = Number(quotaValue)
if (Number.isFinite(value) && value > 0) {
setQuotaValue(formatQuotaValue(value * UNIT_BYTES[quotaUnit], nextUnit))
}
setQuotaUnit(nextUnit)
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!user) return
const value = Number(quotaValue)
if (!Number.isFinite(value) || value <= 0) {
toast.error(t('admin.users.positiveQuotaRequired'))
return
}
mutation.mutate({ orgId: user.orgId, quota: Math.round(value * UNIT_BYTES[quotaUnit]) })
}
if (!user) return null
const used = formatStorage(user.quotaUsed)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.users.setQuotaFor', { name: user.name })}</DialogTitle>
<DialogDescription>{t('admin.users.currentUsage', { used })}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="quota">{t('admin.users.quotaLabel')}</Label>
<div className="flex items-center gap-2">
<Input
id="quota"
type="number"
min="0.1"
step="0.1"
value={quotaValue}
onChange={(e) => setQuotaValue(e.target.value)}
placeholder="10"
required
/>
<Select value={quotaUnit} onValueChange={(value) => handleUnitChange(value as QuotaUnit)}>
<SelectTrigger className="w-24" aria-label={t('admin.users.quotaUnit')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUOTA_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t('admin.users.positiveQuotaHint')}</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function formatQuotaValue(bytes: number, unit: QuotaUnit): string {
return Number((bytes / UNIT_BYTES[unit]).toFixed(2)).toString()
}
function formatStorage(bytes: number): string {
if (bytes >= UNIT_BYTES.TB) return `${formatQuotaValue(bytes, 'TB')} TB`
if (bytes >= UNIT_BYTES.GB) return `${formatQuotaValue(bytes, 'GB')} GB`
return `${formatQuotaValue(bytes, 'MB')} MB`
}
+10 -4
View File
@@ -54,6 +54,14 @@ afterEach(() => {
})
describe('QuotaPanel', () => {
it('keeps the storage area visible while quota is loading', () => {
vi.mocked(getUserQuota).mockReturnValue(new Promise(() => {}) as ReturnType<typeof getUserQuota>)
const view = renderQuotaPanel()
expect(view.getByRole('link', { name: 'quota.storage' }).getAttribute('href')).toBe('/storage')
})
it('keeps the storage area clickable when the store is unavailable', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
@@ -98,8 +106,7 @@ describe('QuotaPanel', () => {
const view = renderQuotaPanel()
await waitFor(() => expect(view.getByRole('link', { name: 'quota.storage' })).toBeTruthy())
expect(view.getByText('quota.usage:25 B/100 B')).toBeTruthy()
await waitFor(() => expect(view.getByText('quota.usage:25 B/100 B')).toBeTruthy())
})
it('shows total storage without plan or extra storage names', async () => {
@@ -122,8 +129,7 @@ describe('QuotaPanel', () => {
const view = renderQuotaPanel()
await waitFor(() => expect(view.getByRole('link', { name: 'quota.storage' })).toBeTruthy())
expect(view.getByText('quota.usage:25 B/200 B')).toBeTruthy()
await waitFor(() => expect(view.getByText('quota.usage:25 B/200 B')).toBeTruthy())
expect(view.queryByText('Team Plan · 100 B')).toBeNull()
expect(view.queryByText('quota.cloudStorageEntitlement:Storage Pack · 100 B')).toBeNull()
})
+14 -9
View File
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { HardDrive } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserQuota } from '@/lib/api'
import { useActiveOrganization } from '@/lib/auth-client'
import { formatSize } from '@/lib/format'
@@ -10,15 +11,15 @@ export function QuotaPanel({ enabled }: { enabled: boolean }) {
const { t } = useTranslation()
const { data: activeOrg } = useActiveOrganization()
const workspaceId = activeOrg?.id ?? 'personal'
const { data: quota } = useQuery({
const { data: quota, isLoading } = useQuery({
queryKey: ['user', 'quota', workspaceId],
queryFn: getUserQuota,
enabled,
})
if (!quota) return null
if (!enabled) return null
const storagePercent = quota.quota > 0 ? Math.round((quota.used / quota.quota) * 100) : null
const storagePercent = quota && quota.quota > 0 ? Math.round((quota.used / quota.quota) * 100) : null
return (
<Link
@@ -33,7 +34,7 @@ export function QuotaPanel({ enabled }: { enabled: boolean }) {
<span className="ml-auto tabular-nums text-muted-foreground">{storagePercent}%</span>
)}
</div>
{quota.quota > 0 && (
{quota && 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"
@@ -41,11 +42,15 @@ export function QuotaPanel({ enabled }: { enabled: boolean }) {
/>
</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>
{quota ? (
<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>
) : (
<Skeleton className={isLoading ? 'h-3 w-24' : 'h-3 w-16 opacity-50'} />
)}
</Link>
)
}
+117 -34
View File
@@ -1,5 +1,5 @@
import type { CloudProduct } from '@shared/types'
import { HardDrive, PlusCircle } from 'lucide-react'
import { BadgeCent, HardDrive, PlusCircle } from 'lucide-react'
import type * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
@@ -10,23 +10,35 @@ import { formatSize } from '@/lib/format'
export function StoragePackages({
packages,
disabled,
currentPlan,
onCheckout,
onManagePlan,
}: {
packages: CloudProduct[]
disabled: boolean
currentPlan?: { packageId: string | null; storageBytes: number } | null
onCheckout: (packageId: string, priceId: string) => void
onManagePlan?: () => void
}) {
const { t, i18n } = useTranslation()
const language = i18n.resolvedLanguage ?? 'en'
return (
<section className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{t('storage.availableProductsTitle')}</h3>
<h3 className="text-lg font-semibold">{t('storage.availablePlansTitle')}</h3>
<p className="text-sm text-muted-foreground">{t('storage.availableProductsDescription')}</p>
</div>
<div className="grid grid-cols-[340px] gap-5 lg:grid-cols-[repeat(2,340px)] xl:grid-cols-[repeat(3,340px)]">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{packages.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} disabled={disabled} language={language} onCheckout={onCheckout} />
<PackageCard
key={pkg.id}
pkg={pkg}
disabled={disabled}
currentPlan={currentPlan}
language={language}
onCheckout={onCheckout}
onManagePlan={onManagePlan}
/>
))}
</div>
{packages.length === 0 && (
@@ -61,8 +73,8 @@ function ProductCardShell({
<div
className={
active
? 'flex h-[380px] w-[340px] flex-col overflow-hidden rounded-lg border border-primary/50 bg-card p-5 text-card-foreground shadow-sm'
: 'flex h-[380px] w-[340px] flex-col overflow-hidden rounded-lg border border-border/60 bg-card p-5 text-card-foreground shadow-sm transition-colors hover:border-primary/50'
? 'flex min-h-[360px] min-w-0 flex-col overflow-hidden rounded-lg border border-primary/60 bg-primary/5 p-5 text-card-foreground shadow-sm'
: 'flex min-h-[360px] min-w-0 flex-col overflow-hidden rounded-lg border border-border/60 bg-card p-5 text-card-foreground shadow-sm transition-colors hover:border-primary/50'
}
>
<div className="flex min-h-[58px] items-start justify-between gap-3">
@@ -84,7 +96,7 @@ function ProductCardShell({
{price}
</div>
<div className="mt-3 shrink-0 space-y-3 rounded-md border bg-muted/20 p-4">{children}</div>
<div className="mt-4 shrink-0">{action}</div>
<div className="mt-auto pt-4">{action}</div>
</div>
)
}
@@ -92,13 +104,17 @@ function ProductCardShell({
function PackageCard({
pkg,
disabled,
currentPlan,
language,
onCheckout,
onManagePlan,
}: {
pkg: CloudProduct
disabled: boolean
currentPlan?: { packageId: string | null; storageBytes: number } | null
language: string
onCheckout: (packageId: string, priceId: string) => void
onManagePlan?: () => void
}) {
const { t } = useTranslation()
const prices = selectPlanPrices(pkg.prices)
@@ -107,46 +123,113 @@ function PackageCard({
const priceLabel = formatPlanPrice(primaryPrice, language, t)
const storageBytes = cloudProductStorageBytes(pkg)
const includedCredits = cloudProductIncludedCredits(pkg)
const isCurrent = currentPlan?.packageId === pkg.id
const hasPlan = Boolean(currentPlan)
const isHigherPlan = hasPlan && storageBytes > (currentPlan?.storageBytes ?? 0)
return (
<ProductCardShell
active={isCurrent}
title={pkg.name}
description={pkg.description ?? ''}
badge={t('storage.planBadge')}
badge={isCurrent ? t('storage.currentPlanBadge') : t('storage.planBadge')}
icon={<HardDrive className="h-4 w-4" />}
price={priceLabel}
action={
<div className="grid gap-2">
{prices.monthly && (
<Button className="h-9 w-full" disabled={disabled} onClick={() => onCheckout(pkg.id, prices.monthly!.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutMonthly')}
</Button>
)}
{prices.yearly && (
<Button
className="h-9 w-full"
variant={prices.monthly ? 'outline' : 'default'}
disabled={disabled}
onClick={() => onCheckout(pkg.id, prices.yearly!.id)}
>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutYearly')}
</Button>
)}
</div>
}
action={planActions({
disabled,
hasPlan,
isCurrent,
isHigherPlan,
prices,
pkgId: pkg.id,
t,
onCheckout,
onManagePlan,
})}
>
<PlanDetailRow label={t('storage.baseStorageQuota')} value={formatSize(storageBytes)} />
<PlanDetailRow label={t('storage.includedCredits')} value={formatCredits(includedCredits)} />
<PlanDetailRow label={t('storage.trafficPolicy')} value={t('storage.usageBilledWithCredits')} />
<PlanDetailRow
icon={<HardDrive className="h-4 w-4" />}
label={t('storage.storageQuota')}
value={formatSize(storageBytes)}
/>
<PlanDetailRow
icon={<BadgeCent className="h-4 w-4" />}
label={t('storage.includedCredits')}
value={formatCredits(includedCredits)}
/>
</ProductCardShell>
)
}
function PlanDetailRow({ label, value }: { label: string; value: string }) {
function planActions({
disabled,
hasPlan,
isCurrent,
isHigherPlan,
prices,
pkgId,
t,
onCheckout,
onManagePlan,
}: {
disabled: boolean
hasPlan: boolean
isCurrent: boolean
isHigherPlan: boolean
prices: ReturnType<typeof selectPlanPrices>
pkgId: string
t: ReturnType<typeof useTranslation>['t']
onCheckout: (packageId: string, priceId: string) => void
onManagePlan?: () => void
}) {
if (hasPlan) {
const label = isCurrent
? t('storage.managePlan')
: isHigherPlan
? t('storage.upgradeToPlan')
: t('storage.changePlan')
return (
<Button
className="h-9 w-full"
variant={isCurrent ? 'outline' : 'default'}
disabled={disabled || !onManagePlan}
onClick={onManagePlan}
>
<PlusCircle className="h-3.5 w-3.5" />
{label}
</Button>
)
}
return (
<div className="grid gap-2">
{prices.monthly && (
<Button className="h-9 w-full" disabled={disabled} onClick={() => onCheckout(pkgId, prices.monthly!.id)}>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutMonthly')}
</Button>
)}
{prices.yearly && (
<Button
className="h-9 w-full"
variant={prices.monthly ? 'outline' : 'default'}
disabled={disabled}
onClick={() => onCheckout(pkgId, prices.yearly!.id)}
>
<PlusCircle className="h-3.5 w-3.5" />
{t('storage.checkoutYearly')}
</Button>
)}
</div>
)
}
function PlanDetailRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 text-sm leading-6">
<span className="truncate text-muted-foreground">{label}</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<span className="shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</span>
<span className="max-w-[128px] shrink-0 truncate text-right font-medium tabular-nums">{value}</span>
</div>
)
+30 -148
View File
@@ -3,14 +3,12 @@ import type * as React from '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 { Progress } from '@/components/ui/progress'
import type { UserQuota } from '@/lib/api'
import { formatSize } from '@/lib/format'
export function CurrentPlanCard({
quota,
creditsBalance,
onManagePlan,
isManagingPlan,
}: {
@@ -24,41 +22,34 @@ export function CurrentPlanCard({
const title = plan?.name ?? quota.storagePlanName ?? quota.trafficPlanName ?? t('storage.currentPlan')
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="border-b">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 space-y-2">
<div className="rounded-lg border border-border/60 bg-card p-5 shadow-sm">
<div className="grid gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="min-w-0 border-b pb-5 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-5">
<div className="text-xs font-medium uppercase text-muted-foreground">{t('storage.currentPlan')}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<div className="min-w-0 truncate text-xl font-semibold">{title}</div>
<div className="flex flex-wrap items-center gap-2">
<Badge>{t('storage.planActive')}</Badge>
{plan?.expiresAt && <Badge variant="outline">{new Date(plan.expiresAt).toLocaleDateString()}</Badge>}
</div>
<div>
<CardDescription>{t('storage.currentPlan')}</CardDescription>
<CardTitle className="mt-1 text-3xl">{title}</CardTitle>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">{t('storage.currentPlanDescription')}</p>
</div>
<Button variant="outline" disabled={isManagingPlan} onClick={onManagePlan}>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{t('storage.currentPlanDescription')}</p>
<Button className="mt-3 h-8" size="sm" variant="outline" disabled={isManagingPlan} onClick={onManagePlan}>
{t('storage.managePlan')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
<UsageRows quota={quota} />
<PlanEntitlementSummary quota={quota} creditsBalance={creditsBalance} />
</div>
</CardContent>
</Card>
<PlanUsageOverview quota={quota} />
</div>
</div>
)
}
export function FreeQuotaCard({ quota }: { quota?: UserQuota }) {
export function FreeQuotaCard({ quota }: { quota?: UserQuota; creditsBalance?: number }) {
const { t } = useTranslation()
return (
<div className="max-w-5xl rounded-lg border border-border/60 bg-card p-4 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[240px_minmax(0,1fr)] lg:items-center">
<div className="min-w-0 border-b pb-4 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-4">
<div className="rounded-lg border border-border/60 bg-card p-5 shadow-sm">
<div className="grid gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="min-w-0 border-b pb-5 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-5">
<div className="text-xs font-medium uppercase text-muted-foreground">{t('storage.currentPlan')}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<div className="text-xl font-semibold">{t('storage.freePlanName')}</div>
@@ -69,7 +60,7 @@ export function FreeQuotaCard({ quota }: { quota?: UserQuota }) {
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{t('storage.freePlanDescription')}</p>
</div>
{quota ? (
<FreeQuotaUsage quota={quota} />
<PlanUsageOverview quota={quota} />
) : (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
)}
@@ -78,78 +69,20 @@ export function FreeQuotaCard({ quota }: { quota?: UserQuota }) {
)
}
function FreeQuotaUsage({ quota }: { quota: UserQuota }) {
function PlanUsageOverview({ quota }: { quota: UserQuota }) {
const { t } = useTranslation()
return (
<div className="grid gap-3 md:grid-cols-2">
<CompactQuotaMetric
<div className="grid gap-4 xl:grid-cols-2">
<UsageMeter
icon={<HardDrive className="h-4 w-4" />}
label={t('storage.storageUsage')}
used={quota.used}
total={quota.quota}
footer={t('storage.storageQuotaDetail', {
used: formatSize(quota.used),
base: formatSize(quota.baseQuota),
cloud: formatSize(quota.entitlementQuota),
})}
/>
<CompactQuotaMetric
label={t('storage.currentPeriodTraffic')}
used={quota.trafficUsed}
total={quota.trafficQuota}
footer={t('storage.trafficPeriodDetail', { period: quota.trafficPeriod })}
/>
</div>
)
}
function CompactQuotaMetric({
label,
used,
total,
footer,
}: {
label: string
used: number
total: number
footer: string
}) {
const { t } = useTranslation()
const percent = total > 0 ? Math.min(100, (used / total) * 100) : 100
return (
<div className="min-w-0 rounded-md bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-muted-foreground">{label}</span>
<span className="shrink-0 text-muted-foreground">
{total > 0 ? t('storage.usageTotal', { total: formatSize(total) }) : t('storage.usageNoLimit')}
</span>
</div>
<div className="mt-2 flex items-center gap-3">
<span className="shrink-0 text-xl font-semibold tabular-nums">{formatSize(used)}</span>
<Progress value={percent} className="h-2 min-w-0 flex-1" />
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">{footer}</p>
</div>
)
}
function UsageRows({ quota, compact = false }: { quota: UserQuota; compact?: boolean }) {
const { t } = useTranslation()
return (
<div className={compact ? 'space-y-5' : 'grid gap-6 md:grid-cols-2'}>
<UsageMeter
icon={<HardDrive className="h-4 w-4" />}
label={t('storage.effectiveStorageQuota')}
used={quota.used}
total={quota.quota}
detail={t('storage.storageQuotaDetail', {
used: formatSize(quota.used),
base: formatSize(quota.baseQuota),
cloud: formatSize(quota.entitlementQuota),
})}
detail={t('storage.storageUsageDetail', { used: formatSize(quota.used) })}
/>
<UsageMeter
icon={<Activity className="h-4 w-4" />}
label={t('storage.currentPeriodTraffic')}
label={t('storage.trafficUsage')}
used={quota.trafficUsed}
total={quota.trafficQuota}
detail={t('storage.trafficPeriodDetail', { period: quota.trafficPeriod })}
@@ -172,23 +105,21 @@ function UsageMeter({
detail: string
}) {
const { t } = useTranslation()
const overCap = total > 0 && used >= total
const percent = total > 0 ? Math.min(100, (used / total) * 100) : 100
return (
<div className="space-y-3">
<div className="min-w-0 rounded-md bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
<span className="text-muted-foreground">{icon}</span>
<span>{label}</span>
</div>
{overCap && <Badge variant="destructive">{t('storage.overCap')}</Badge>}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{total > 0 ? t('storage.usageTotal', { total: formatSize(total) }) : t('storage.usageNoLimit')}
</span>
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="mt-3 space-y-2">
<div className="flex items-baseline justify-between gap-3">
<span className="text-2xl font-semibold tabular-nums">{formatSize(used)}</span>
<span className="text-sm text-muted-foreground">
{total > 0 ? t('storage.usageTotal', { total: formatSize(total) }) : t('storage.usageNoLimit')}
</span>
</div>
<Progress value={percent} className="h-2" />
<p className="text-xs text-muted-foreground">{detail}</p>
@@ -196,52 +127,3 @@ function UsageMeter({
</div>
)
}
function PlanEntitlementSummary({ quota, creditsBalance }: { quota: UserQuota; creditsBalance?: number }) {
const { t } = useTranslation()
const plan = quota.currentPlan
const rows = [
{
label: t('storage.baseStorageQuota'),
value: formatQuotaPlanValue(plan?.storageBytes ?? quota.baseQuota, quota.storagePlanName),
},
{
label: t('storage.cloudStorageEntitlement'),
value: formatExtraValue(quota.entitlementQuota, quota.storageExtraNames),
},
{
label: t('storage.creditBalance'),
value: creditsBalance === undefined ? t('common.loading') : formatCredits(creditsBalance),
},
{
label: t('storage.trafficPolicy'),
value: t('storage.usageBilledWithCredits'),
},
]
return (
<div className="space-y-4 border-t pt-5 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
{rows.map((row) => (
<div key={row.label} className="flex items-start justify-between gap-3">
<div className="text-sm text-muted-foreground">{row.label}</div>
<div className="max-w-[160px] text-right text-sm font-medium tabular-nums">{row.value}</div>
</div>
))}
</div>
)
}
function formatQuotaPlanValue(bytes: number, planName: string | null) {
const size = formatSize(bytes)
return bytes > 0 && planName ? `${planName} · ${size}` : size
}
function formatExtraValue(bytes: number, names: string[]) {
const size = formatSize(bytes)
if (bytes <= 0 || names.length === 0) return size
if (names.length === 1) return `${names[0]} · ${size}`
return `${names[0]} +${names.length - 1} · ${size}`
}
function formatCredits(credits: number) {
return new Intl.NumberFormat().format(credits)
}
-47
View File
@@ -20,14 +20,8 @@ const ADMIN_USERS_KEYS = [
'admin.users.disabled',
'admin.users.enable',
'admin.users.disable',
'admin.users.setQuota',
'admin.users.setQuotaFor',
'admin.users.quotaLabel',
'admin.users.quotaUnit',
'admin.users.currentUsage',
'admin.users.quotaUpdated',
'admin.users.positiveQuotaRequired',
'admin.users.positiveQuotaHint',
'admin.users.statusUpdated',
'admin.users.selectedCount',
'admin.users.selectedUsers',
@@ -35,12 +29,10 @@ const ADMIN_USERS_KEYS = [
'admin.users.selectUser',
'admin.users.batchDisable',
'admin.users.batchEnable',
'admin.users.batchSetQuota',
'admin.users.batchDelete',
'admin.users.batchDeleteConfirm',
'admin.users.batchStatusUpdated',
'admin.users.batchDeleted',
'admin.users.batchQuotaUpdated',
'admin.users.deleteTitle',
'admin.users.deleteConfirm',
'admin.users.userDeleted',
@@ -79,15 +71,12 @@ const ADMIN_USERS_KEYS = [
// Keys that contain interpolation placeholders and the expected placeholder tokens
const INTERPOLATED_KEYS: Record<string, string[]> = {
'admin.users.setQuotaFor': ['{{name}}'],
'admin.users.currentUsage': ['{{used}}'],
'admin.users.selectedCount': ['{{count}}'],
'admin.users.selectedUsers': ['{{count}}'],
'admin.users.selectUser': ['{{name}}'],
'admin.users.batchDeleteConfirm': ['{{count}}'],
'admin.users.batchStatusUpdated': ['{{count}}'],
'admin.users.batchDeleted': ['{{count}}'],
'admin.users.batchQuotaUpdated': ['{{count}}'],
'admin.users.deleteConfirm': ['{{name}}'],
'admin.users.pageInfo': ['{{page}}', '{{total}}'],
'admin.users.pageSizeOption': ['{{count}}'],
@@ -153,14 +142,6 @@ describe('admin.users locale keys — English values contract', () => {
expect(enLocale['admin.users.disable']).toBe('Disable')
})
it('admin.users.setQuota is "Set Quota"', () => {
expect(enLocale['admin.users.setQuota']).toBe('Set Quota')
})
it('admin.users.quotaUpdated is "Quota updated"', () => {
expect(enLocale['admin.users.quotaUpdated']).toBe('Quota updated')
})
it('admin.users.statusUpdated is "User status updated"', () => {
expect(enLocale['admin.users.statusUpdated']).toBe('User status updated')
})
@@ -188,10 +169,6 @@ describe('admin.users locale keys — English values contract', () => {
it('admin.users.searchPlaceholder is "Search by name, username, or email"', () => {
expect(enLocale['admin.users.searchPlaceholder']).toBe('Search by name, username, or email')
})
it('admin.users.quotaLabel is "Quota"', () => {
expect(enLocale['admin.users.quotaLabel']).toBe('Quota')
})
})
describe('admin.users locale keys — i18n runtime translation', () => {
@@ -231,24 +208,6 @@ describe('admin.users locale keys — i18n runtime translation', () => {
expect(i18n.t('admin.users.disabled')).toBe('已禁用')
})
it('interpolates admin.users.setQuotaFor with name in English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.users.setQuotaFor', { name: 'Alice' })).toBe('Set storage quota for Alice')
})
it('interpolates admin.users.setQuotaFor with name in Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.users.setQuotaFor', { name: 'Alice' })).toBe('为 Alice 设置存储配额')
})
it('interpolates admin.users.currentUsage with used value in English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
expect(i18n.t('admin.users.currentUsage', { used: '2.50 GB' })).toBe('Current usage: 2.50 GB')
})
it('interpolates admin.users.deleteConfirm with name in English', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('en')
@@ -281,12 +240,6 @@ describe('admin.users locale keys — i18n runtime translation', () => {
expect(i18n.t('admin.users.deleteTitle')).toBe('删除用户')
})
it('translates admin.users.quotaUpdated to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
expect(i18n.t('admin.users.quotaUpdated')).toBe('配额已更新')
})
it('translates admin.users.userDeleted to Chinese', async () => {
const { default: i18n } = await import('./index')
await i18n.changeLanguage('zh')
+37 -14
View File
@@ -213,14 +213,35 @@
"admin.users.disabled": "Disabled",
"admin.users.enable": "Enable",
"admin.users.disable": "Disable",
"admin.users.setQuota": "Set Quota",
"admin.users.setQuotaFor": "Set storage quota for {{name}}",
"admin.users.quotaLabel": "Quota",
"admin.users.quotaUnit": "Quota unit",
"admin.users.currentUsage": "Current usage: {{used}}",
"admin.users.quotaUpdated": "Quota updated",
"admin.users.positiveQuotaRequired": "Quota must be greater than 0",
"admin.users.positiveQuotaHint": "Enter a positive quota and choose a unit.",
"admin.users.manageEntitlements": "Manage entitlements",
"admin.users.entitlementsFor": "Quota entitlements for {{name}}",
"admin.users.entitlementsUsage": "Current usage: {{used}} / {{total}}",
"admin.users.entitlementType": "Type",
"admin.users.entitlementAmount": "Amount",
"admin.users.entitlementExpires": "Expires",
"admin.users.entitlementStatus": "Status",
"admin.users.entitlementNote": "Note",
"admin.users.entitlementSource": "Source",
"admin.users.entitlementTypePlan": "Plan",
"admin.users.entitlementTypeGrant": "Grant",
"admin.users.entitlementSourceAdmin": "Admin grant",
"admin.users.entitlementSourceFreePlan": "Free plan",
"admin.users.entitlementSourceOrder": "Order",
"admin.users.noExpiry": "No expiry",
"admin.users.noEntitlements": "No entitlements yet.",
"admin.users.grantEntitlement": "Grant entitlement",
"admin.users.addEntitlement": "Add entitlement",
"admin.users.grantEntitlementFor": "Grant entitlement to {{name}}",
"admin.users.grantEntitlementDescription": "Add a storage or traffic entitlement to this user's personal organization.",
"admin.users.entitlementGranted": "Entitlement granted",
"admin.users.entitlements": "Entitlements",
"admin.users.activeEntitlements": "Active entitlements",
"admin.users.backToUsers": "Back to users",
"admin.users.userDetails": "User details",
"admin.users.userNotFound": "User not found",
"admin.users.revoked": "Revoked",
"admin.users.statusUpdated": "User status updated",
"admin.users.selectedCount": "{{count}} selected",
"admin.users.selectedUsers": "{{count}} selected users",
@@ -228,12 +249,10 @@
"admin.users.selectUser": "Select {{name}}",
"admin.users.batchDisable": "Disable selected",
"admin.users.batchEnable": "Enable selected",
"admin.users.batchSetQuota": "Set quota",
"admin.users.batchDelete": "Delete selected",
"admin.users.batchDeleteConfirm": "Delete {{count}} selected users and all their files? This action cannot be undone.",
"admin.users.batchStatusUpdated": "Updated {{count}} users",
"admin.users.batchDeleted": "Deleted {{count}} users",
"admin.users.batchQuotaUpdated": "Updated quota for {{count}} users",
"admin.users.deleteTitle": "Delete User",
"admin.users.deleteConfirm": "Delete user {{name}} and all their files? This action cannot be undone.",
"admin.users.userDeleted": "User deleted",
@@ -752,6 +771,7 @@
"activity.action.storage_update": "updated storage",
"activity.action.storage_delete": "deleted storage",
"activity.action.quota_update": "updated quota for",
"activity.action.quota_entitlement_grant": "granted quota entitlement for",
"activity.action.invite_code_generate": "generated invite codes",
"activity.action.invite_code_delete": "deleted invite code",
"activity.action.site_invitation_create": "sent site invitation to",
@@ -1243,7 +1263,7 @@
"admin.cloudStore.orders.provider": "Provider",
"admin.cloudStore.orders.noPayments": "No payments recorded.",
"storage.title": "Storage",
"storage.subtitle": "Review the current plan, usage, and available storage plans for this workspace.",
"storage.subtitle": "Review this workspace's storage quota, subscription plan, and Credits.",
"storage.unavailable": "Storage add-ons are not available on this site.",
"storage.unavailableTitle": "Storage add-ons are unavailable",
"storage.unavailableDescription": "This site is not connected to storage add-ons right now. Your existing workspace storage is still available.",
@@ -1256,15 +1276,16 @@
"storage.currentQuota": "Current quota",
"storage.currentTrafficQuota": "Current traffic",
"storage.effectiveStorageQuota": "Effective storage",
"storage.storageQuota": "Storage quota",
"storage.storageUsage": "Storage usage",
"storage.baseStorageQuota": "Plan storage",
"storage.cloudStorageEntitlement": "Extra storage",
"storage.storageQuotaEntitlement": "Total active storage entitlement",
"storage.cloudTrafficEntitlement": "Extra traffic",
"storage.includedTraffic": "Plan traffic",
"storage.currentPeriodTraffic": "Metered traffic",
"storage.trafficUsage": "Traffic usage",
"storage.currentPlan": "Current plan",
"storage.currentPlanDescription": "Storage comes from this plan. Metered usage is billed with Credits.",
"storage.currentPlanDescription": "Storage comes from this subscription plan. Included Credits are granted by the plan.",
"storage.planActive": "Active",
"storage.freePlanName": "Free",
"storage.freePlanDescription": "Starter storage included with this workspace.",
@@ -1278,7 +1299,6 @@
"storage.legendCloudQuota": "Extra quota",
"storage.legendBaseAvailable": "Plan available",
"storage.legendCloudAvailable": "Extra available",
"storage.storageQuotaDetail": "{{used}} used · plan {{base}} · extra {{cloud}}",
"storage.trafficQuotaDetail": "Plan {{base}} · extra {{cloud}}",
"storage.storageUsageDetail": "{{used}} used",
"storage.usedStorage": "Used",
@@ -1314,9 +1334,10 @@
"storage.availablePlansTitle": "Available plans",
"storage.availablePlansDescription": "Upgrade when you need more storage and included Credits.",
"storage.availableProductsTitle": "Available products",
"storage.availableProductsDescription": "Choose a monthly or yearly plan. Usage beyond the included storage is billed with Credits.",
"storage.availableProductsDescription": "Choose a monthly or yearly subscription plan. Credits can be topped up separately.",
"storage.monthlyPlanBadge": "Plan",
"storage.planBadge": "Plan",
"storage.currentPlanBadge": "Current",
"storage.resourcePackageBadge": "Package",
"storage.planBilling": "Billing",
"storage.billingMonthly": "Monthly subscription",
@@ -1338,6 +1359,8 @@
"storage.checkoutYearly": "Subscribe yearly",
"storage.checkoutPackage": "Buy package",
"storage.managePlan": "Manage plan",
"storage.upgradeToPlan": "Upgrade to this plan",
"storage.changePlan": "Change plan",
"storage.planAlreadyActive": "Plan already active",
"storage.checkoutPending": "Waiting for Stripe payment confirmation. Quota and order status will refresh automatically.",
"storage.checkoutRedirectTitle": "Preparing checkout",
@@ -1361,7 +1384,7 @@
"storage.historyTitle": "Recent orders",
"storage.historyDescription": "Recent storage plan orders for the current workspace.",
"storage.noPackages": "No storage plans are available right now.",
"storage.noPackages": "No subscription plans are available right now.",
"storage.trafficQuota": "{{size}} download traffic",
"storage.activeEntitlement": "Active entitlement",
"storage.noHistory": "No storage orders yet."
+37 -14
View File
@@ -213,14 +213,35 @@
"admin.users.disabled": "已禁用",
"admin.users.enable": "启用",
"admin.users.disable": "禁用",
"admin.users.setQuota": "设置配额",
"admin.users.setQuotaFor": "为 {{name}} 设置存储配额",
"admin.users.quotaLabel": "配额",
"admin.users.quotaUnit": "配额单位",
"admin.users.currentUsage": "当前用量:{{used}}",
"admin.users.quotaUpdated": "配额已更新",
"admin.users.positiveQuotaRequired": "配额必须大于 0",
"admin.users.positiveQuotaHint": "请输入大于 0 的配额并选择单位。",
"admin.users.manageEntitlements": "管理权益",
"admin.users.entitlementsFor": "{{name}} 的额度权益",
"admin.users.entitlementsUsage": "当前用量:{{used}} / {{total}}",
"admin.users.entitlementType": "类型",
"admin.users.entitlementAmount": "额度",
"admin.users.entitlementExpires": "过期时间",
"admin.users.entitlementStatus": "状态",
"admin.users.entitlementNote": "备注",
"admin.users.entitlementSource": "来源",
"admin.users.entitlementTypePlan": "套餐",
"admin.users.entitlementTypeGrant": "赠送",
"admin.users.entitlementSourceAdmin": "后台发放",
"admin.users.entitlementSourceFreePlan": "免费套餐",
"admin.users.entitlementSourceOrder": "订单",
"admin.users.noExpiry": "长期有效",
"admin.users.noEntitlements": "暂无额度权益。",
"admin.users.grantEntitlement": "发放权益",
"admin.users.addEntitlement": "新增权益",
"admin.users.grantEntitlementFor": "给 {{name}} 发放权益",
"admin.users.grantEntitlementDescription": "给该用户的个人组织增加一条存储或流量权益。",
"admin.users.entitlementGranted": "权益已发放",
"admin.users.entitlements": "额度权益",
"admin.users.activeEntitlements": "有效权益",
"admin.users.backToUsers": "返回用户列表",
"admin.users.userDetails": "用户详情",
"admin.users.userNotFound": "未找到用户",
"admin.users.revoked": "已撤销",
"admin.users.statusUpdated": "用户状态已更新",
"admin.users.selectedCount": "已选择 {{count}} 个",
"admin.users.selectedUsers": "已选择 {{count}} 个用户",
@@ -228,12 +249,10 @@
"admin.users.selectUser": "选择 {{name}}",
"admin.users.batchDisable": "禁用所选",
"admin.users.batchEnable": "启用所选",
"admin.users.batchSetQuota": "设置配额",
"admin.users.batchDelete": "删除所选",
"admin.users.batchDeleteConfirm": "删除选中的 {{count}} 个用户及其所有文件?此操作无法撤销。",
"admin.users.batchStatusUpdated": "已更新 {{count}} 个用户",
"admin.users.batchDeleted": "已删除 {{count}} 个用户",
"admin.users.batchQuotaUpdated": "已更新 {{count}} 个用户的配额",
"admin.users.deleteTitle": "删除用户",
"admin.users.deleteConfirm": "删除用户 {{name}} 及其所有文件?此操作无法撤销。",
"admin.users.userDeleted": "用户已删除",
@@ -752,6 +771,7 @@
"activity.action.storage_update": "更新了存储",
"activity.action.storage_delete": "删除了存储",
"activity.action.quota_update": "更新了配额",
"activity.action.quota_entitlement_grant": "发放了额度权益",
"activity.action.invite_code_generate": "生成了邀请码",
"activity.action.invite_code_delete": "删除了邀请码",
"activity.action.site_invitation_create": "发送了站点邀请",
@@ -1243,7 +1263,7 @@
"admin.cloudStore.orders.provider": "支付渠道",
"admin.cloudStore.orders.noPayments": "暂无支付记录。",
"storage.title": "存储",
"storage.subtitle": "查看当前工作空间的套餐、用量和可购买的存储计划。",
"storage.subtitle": "查看当前工作空间的存储额度、订阅套餐和 Credits。",
"storage.unavailable": "当前站点暂不可用存储空间扩展。",
"storage.unavailableTitle": "存储空间扩展暂不可用",
"storage.unavailableDescription": "当前站点暂未连接存储空间扩展服务。你仍然可以继续使用工作空间已有的存储空间。",
@@ -1256,15 +1276,16 @@
"storage.currentQuota": "当前配额",
"storage.currentTrafficQuota": "当前流量",
"storage.effectiveStorageQuota": "有效存储",
"storage.storageQuota": "存储额度",
"storage.storageUsage": "存储用量",
"storage.baseStorageQuota": "套餐存储",
"storage.cloudStorageEntitlement": "额外存储",
"storage.storageQuotaEntitlement": "当前有效存储额度",
"storage.cloudTrafficEntitlement": "额外流量",
"storage.includedTraffic": "套餐流量",
"storage.currentPeriodTraffic": "计量流量",
"storage.trafficUsage": "流量用量",
"storage.currentPlan": "当前套餐",
"storage.currentPlanDescription": "存储空间由当前套餐提供,计量用量使用 Credits 结算。",
"storage.currentPlanDescription": "存储空间由当前订阅套餐提供,套餐会发放包含的 Credits。",
"storage.planActive": "已开通",
"storage.freePlanName": "免费版",
"storage.freePlanDescription": "当前工作空间默认包含的基础存储额度。",
@@ -1278,7 +1299,6 @@
"storage.legendCloudQuota": "额外额度",
"storage.legendBaseAvailable": "套餐可用",
"storage.legendCloudAvailable": "额外可用",
"storage.storageQuotaDetail": "已用 {{used}} · 套餐 {{base}} · 额外 {{cloud}}",
"storage.trafficQuotaDetail": "套餐 {{base}} · 额外 {{cloud}}",
"storage.storageUsageDetail": "已使用 {{used}}",
"storage.usedStorage": "已用",
@@ -1314,9 +1334,10 @@
"storage.availablePlansTitle": "可购买套餐",
"storage.availablePlansDescription": "当你需要更多存储空间和包含的 Credits 时,可以升级套餐。",
"storage.availableProductsTitle": "可购买商品",
"storage.availableProductsDescription": "可以选择月付或年付套餐,超出包含额度后的用量使用 Credits 结算。",
"storage.availableProductsDescription": "可以选择月付或年付订阅套餐,Credits 可单独充值。",
"storage.monthlyPlanBadge": "套餐",
"storage.planBadge": "套餐",
"storage.currentPlanBadge": "当前方案",
"storage.resourcePackageBadge": "资源包",
"storage.planBilling": "计费方式",
"storage.billingMonthly": "按月订阅",
@@ -1338,6 +1359,8 @@
"storage.checkoutYearly": "年付订阅",
"storage.checkoutPackage": "购买资源包",
"storage.managePlan": "管理套餐",
"storage.upgradeToPlan": "升级到此方案",
"storage.changePlan": "更改方案",
"storage.planAlreadyActive": "已有生效套餐",
"storage.checkoutPending": "正在等待 Stripe 支付确认,配额和订单状态会自动刷新。",
"storage.checkoutRedirectTitle": "正在准备支付",
@@ -1361,7 +1384,7 @@
"storage.historyTitle": "最近订单",
"storage.historyDescription": "当前工作空间近期的存储计划订单。",
"storage.noPackages": "当前暂无可用存储计划。",
"storage.noPackages": "当前暂无可用订阅套餐。",
"storage.trafficQuota": "{{size}} 下载流量",
"storage.activeEntitlement": "有效权益",
"storage.noHistory": "暂无存储订单。"
+7 -2
View File
@@ -13,8 +13,9 @@ const STORAGE_METERING_KEYS = [
'quota.purchasedTraffic',
'storage.effectiveStorageQuota',
'storage.storageUsage',
'storage.baseStorageQuota',
'storage.cloudStorageEntitlement',
'storage.storageQuota',
'storage.storageQuotaEntitlement',
'storage.includedTraffic',
'storage.currentPeriodTraffic',
'storage.trafficUsage',
@@ -28,7 +29,6 @@ const STORAGE_METERING_KEYS = [
'storage.freePlanPrice',
'storage.usageTotal',
'storage.usageNoLimit',
'storage.storageQuotaDetail',
'storage.trafficQuotaDetail',
'storage.usedStorage',
'storage.trafficPeriodDetail',
@@ -46,6 +46,8 @@ const STORAGE_METERING_KEYS = [
'storage.availableProductsTitle',
'storage.availableProductsDescription',
'storage.monthlyPlanBadge',
'storage.planBadge',
'storage.currentPlanBadge',
'storage.resourcePackageBadge',
'storage.planBilling',
'storage.billingMonthly',
@@ -60,6 +62,9 @@ const STORAGE_METERING_KEYS = [
'storage.trafficOverageEnabled',
'storage.trafficOveragePerGb',
'storage.checkoutPackage',
'storage.managePlan',
'storage.upgradeToPlan',
'storage.changePlan',
'admin.cloudStore.planName',
'admin.cloudStore.noPlans',
'admin.cloudStore.orders.planQuota',
+61 -50
View File
@@ -5,7 +5,6 @@ import {
batchDeleteUsers,
batchMoveObjects,
batchTrashObjects,
batchUpdateUserQuota,
batchUpdateUserStatus,
buildShareObjectUrl,
cancelBackgroundJob,
@@ -61,6 +60,7 @@ import {
getSystemOption,
getUnreadCount,
getUserQuota,
grantUserEntitlement,
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
@@ -86,6 +86,7 @@ import {
listSiteInvitations,
listStorages,
listSystemOptions,
listUserEntitlements,
listUsers,
listWebDavAppPasswords,
markAllNotificationsRead,
@@ -111,7 +112,6 @@ import {
updateCloudStoreSettings,
updateIhostConfig,
updateObject,
updateQuota,
updateStorage,
updateUserStatus,
uploadAvatar,
@@ -1290,25 +1290,73 @@ describe('api', () => {
})
})
describe('batchUpdateUserQuota', () => {
it('sets quota for selected users through batch endpoint', async () => {
const payload = { updated: 2, userIds: ['u1', 'u2'], orgIds: ['o1', 'o2'], quota: 2048 }
describe('user entitlements', () => {
it('lists user quota entitlements', async () => {
const payload = {
orgId: 'org-1',
items: [
{
id: 'ent-1',
orgId: 'org-1',
resourceType: 'storage',
entitlementType: 'plan',
source: 'free_plan',
sourceId: 'free_plan:org-1',
bytes: 1024,
startsAt: '2026-05-01T00:00:00.000Z',
expiresAt: null,
status: 'active',
metadata: null,
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
},
],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await batchUpdateUserQuota(['u1', 'u2'], 2048)
const result = await listUserEntitlements('u1')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/users/batch')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ action: 'set_quota', ids: ['u1', 'u2'], quota: 2048 })
expect(url).toContain('/api/admin/users/u1/entitlements')
expect(init.method).toBe('GET')
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'quota required' }, false, 400))
it('grants a user quota entitlement', async () => {
const payload = {
orgId: 'org-1',
entitlement: {
id: 'ent-2',
orgId: 'org-1',
resourceType: 'storage',
entitlementType: 'grant',
source: 'admin_grant',
sourceId: 'admin_grant:1',
bytes: 2048,
startsAt: '2026-05-01T00:00:00.000Z',
expiresAt: null,
status: 'active',
metadata: null,
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
},
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await expect(batchUpdateUserQuota(['u1'], 0)).rejects.toThrow('quota required')
const result = await grantUserEntitlement('u1', { resourceType: 'storage', bytes: 2048, expiresAt: null })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/users/u1/entitlements')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ resourceType: 'storage', bytes: 2048, expiresAt: null })
})
it('throws on entitlement error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(listUserEntitlements('missing')).rejects.toThrow('not found')
})
})
@@ -1351,43 +1399,6 @@ describe('api', () => {
})
})
describe('updateQuota', () => {
it('puts quota for an org and returns updated quota', async () => {
const updated = {
orgId: 'org1',
baseQuota: 2048,
entitlementQuota: 0,
quota: 2048,
used: 512,
baseTrafficQuota: 4096,
entitlementTrafficQuota: 0,
trafficQuota: 4096,
trafficUsed: 256,
trafficPeriod: '2026-05',
storagePlanName: null,
storageExtraNames: [],
trafficPlanName: null,
trafficExtraNames: [],
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
const result = await updateQuota('org1', 2048, 4096)
expect(result).toEqual(updated)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/quotas/org1')
expect(init.method).toBe('PUT')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ quota: 2048, trafficQuota: 4096 })
})
it('throws on error response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(updateQuota('missing', 100)).rejects.toThrow('not found')
})
})
describe('getUserQuota', () => {
it('fetches the current user quota', async () => {
const payload = {
+17 -12
View File
@@ -37,6 +37,7 @@ import type {
ImageHosting,
Notification,
OrgQuota,
OrgQuotaEntitlement,
PaginatedResponse,
ShareListItem,
ShareView,
@@ -303,12 +304,18 @@ export interface UserWithOrg {
quotaTotal: number
}
export type UserEntitlementsResponse = { orgId: string; items: OrgQuotaEntitlement[] }
export function listUsers(page: number, pageSize: number, search?: string) {
const query: Record<string, string> = { page: String(page), pageSize: String(pageSize) }
if (search?.trim()) query.search = search.trim()
return unwrap<{ items: UserWithOrg[]; total: number }>(users.index.$get({ query }))
}
export function getUser(userId: string) {
return unwrap<UserWithOrg>(users[':id'].$get({ param: { id: userId } }))
}
export function updateUserStatus(userId: string, status: 'active' | 'disabled') {
return unwrap<{ id: string; status: string }>(users[':id'].$patch({ param: { id: userId }, json: { status } }))
}
@@ -326,9 +333,16 @@ export function batchDeleteUsers(ids: string[]) {
return unwrap<{ deleted: number; ids: string[] }>(users.batch.$delete({ json: { ids } }))
}
export function batchUpdateUserQuota(ids: string[], quota: number) {
return unwrap<{ updated: number; userIds: string[]; orgIds: string[]; quota: number }>(
users.batch.$patch({ json: { action: 'set_quota' as const, ids, quota } }),
export function listUserEntitlements(userId: string) {
return unwrap<UserEntitlementsResponse>(users[':id'].entitlements.$get({ param: { id: userId } }))
}
export function grantUserEntitlement(
userId: string,
data: { resourceType: 'storage'; bytes: number; expiresAt?: string | null; note?: string | null },
) {
return unwrap<{ orgId: string; entitlement: OrgQuotaEntitlement }>(
users[':id'].entitlements.$post({ param: { id: userId }, json: data }),
)
}
@@ -355,15 +369,6 @@ export function listQuotas() {
return unwrap<{ items: QuotaItem[]; total: number }>(adminQuotas.index.$get())
}
export function updateQuota(orgId: string, quota: number, trafficQuota?: number) {
return unwrap<QuotaItem>(
adminQuotas[':orgId'].$put({
param: { orgId },
json: trafficQuota == null ? { quota } : { quota, trafficQuota },
}),
)
}
// User Quotas API
export function getUserQuota() {
+22
View File
@@ -48,6 +48,7 @@ import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_a
import { Route as AuthenticatedTeamsTeamIdSettingsRouteImport } from './routes/_authenticated/teams/$teamId/settings'
import { Route as AuthenticatedTeamsTeamIdMembersRouteImport } from './routes/_authenticated/teams/$teamId/members'
import { Route as AuthenticatedTeamsTeamIdActivityRouteImport } from './routes/_authenticated/teams/$teamId/activity'
import { Route as AuthenticatedAdminUsersUserIdRouteImport } from './routes/_authenticated/admin/users/$userId'
import { Route as AuthenticatedAdminSettingsOauthRouteImport } from './routes/_authenticated/admin/settings/oauth'
import { Route as AuthenticatedAdminSettingsEmailRouteImport } from './routes/_authenticated/admin/settings/email'
@@ -267,6 +268,12 @@ const AuthenticatedTeamsTeamIdActivityRoute =
path: '/activity',
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
} as any)
const AuthenticatedAdminUsersUserIdRoute =
AuthenticatedAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
getParentRoute: () => AuthenticatedAdminRouteRoute,
} as any)
const AuthenticatedAdminSettingsOauthRoute =
AuthenticatedAdminSettingsOauthRouteImport.update({
id: '/settings/oauth',
@@ -314,6 +321,7 @@ export interface FileRoutesByFullPath {
'/users/': typeof AuthenticatedUsersIndexRoute
'/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
'/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -353,6 +361,7 @@ export interface FileRoutesByTo {
'/users': typeof AuthenticatedUsersIndexRoute
'/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
'/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -397,6 +406,7 @@ export interface FileRoutesById {
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/admin/settings/email': typeof AuthenticatedAdminSettingsEmailRoute
'/_authenticated/admin/settings/oauth': typeof AuthenticatedAdminSettingsOauthRoute
'/_authenticated/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/_authenticated/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/_authenticated/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
@@ -441,6 +451,7 @@ export interface FileRouteTypes {
| '/users/'
| '/admin/settings/email'
| '/admin/settings/oauth'
| '/admin/users/$userId'
| '/teams/$teamId/activity'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
@@ -480,6 +491,7 @@ export interface FileRouteTypes {
| '/users'
| '/admin/settings/email'
| '/admin/settings/oauth'
| '/admin/users/$userId'
| '/teams/$teamId/activity'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
@@ -523,6 +535,7 @@ export interface FileRouteTypes {
| '/_authenticated/users/'
| '/_authenticated/admin/settings/email'
| '/_authenticated/admin/settings/oauth'
| '/_authenticated/admin/users/$userId'
| '/_authenticated/teams/$teamId/activity'
| '/_authenticated/teams/$teamId/members'
| '/_authenticated/teams/$teamId/settings'
@@ -816,6 +829,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedTeamsTeamIdActivityRouteImport
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
}
'/_authenticated/admin/users/$userId': {
id: '/_authenticated/admin/users/$userId'
path: '/users/$userId'
fullPath: '/admin/users/$userId'
preLoaderRoute: typeof AuthenticatedAdminUsersUserIdRouteImport
parentRoute: typeof AuthenticatedAdminRouteRoute
}
'/_authenticated/admin/settings/oauth': {
id: '/_authenticated/admin/settings/oauth'
path: '/settings/oauth'
@@ -841,6 +861,7 @@ interface AuthenticatedAdminRouteRouteChildren {
AuthenticatedAdminIndexRoute: typeof AuthenticatedAdminIndexRoute
AuthenticatedAdminSettingsEmailRoute: typeof AuthenticatedAdminSettingsEmailRoute
AuthenticatedAdminSettingsOauthRoute: typeof AuthenticatedAdminSettingsOauthRoute
AuthenticatedAdminUsersUserIdRoute: typeof AuthenticatedAdminUsersUserIdRoute
AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute
AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute
AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute
@@ -855,6 +876,7 @@ const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren
AuthenticatedAdminIndexRoute: AuthenticatedAdminIndexRoute,
AuthenticatedAdminSettingsEmailRoute: AuthenticatedAdminSettingsEmailRoute,
AuthenticatedAdminSettingsOauthRoute: AuthenticatedAdminSettingsOauthRoute,
AuthenticatedAdminUsersUserIdRoute: AuthenticatedAdminUsersUserIdRoute,
AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute,
AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute,
AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute,
@@ -0,0 +1,222 @@
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link } from '@tanstack/react-router'
import { ArrowLeft, BadgeCent, CalendarDays, Mail } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GrantUserEntitlementDialog } from '@/components/admin/grant-user-entitlement-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { getUser, listUserEntitlements } from '@/lib/api'
import { formatSize } from '@/lib/format'
export const Route = createFileRoute('/_authenticated/admin/users/$userId')({
component: AdminUserDetailPage,
})
function AdminUserDetailPage() {
const { t } = useTranslation()
const { userId } = Route.useParams()
const [grantOpen, setGrantOpen] = useState(false)
const userQuery = useQuery({
queryKey: ['admin', 'users', userId],
queryFn: () => getUser(userId),
})
const entitlementsQuery = useQuery({
queryKey: ['admin', 'users', userId, 'entitlements'],
queryFn: () => listUserEntitlements(userId),
})
const user = userQuery.data
const items = useMemo(
() => (entitlementsQuery.data?.items ?? []).filter((item) => item.resourceType === 'storage'),
[entitlementsQuery.data?.items],
)
const displayName = user ? user.name || user.username || user.email : ''
const statusLabel = user?.banned ? t('admin.users.disabled') : t('admin.users.active')
const statusVariant = user?.banned ? 'destructive' : 'secondary'
const quotaLabel = user ? formatQuota(user.quotaUsed, user.quotaTotal) : ''
const activeItems = useMemo(() => items.filter((item) => item.status === 'active'), [items])
if (userQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<p>{t('common.loading')}</p>
</div>
)
}
if (!user) {
return (
<div className="space-y-4">
<Button variant="outline" asChild>
<Link to="/admin/users">
<ArrowLeft />
{t('admin.users.backToUsers')}
</Link>
</Button>
<div className="rounded-md border px-4 py-8 text-center text-muted-foreground">
{userQuery.error?.message ?? t('admin.users.userNotFound')}
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="outline" asChild>
<Link to="/admin/users">
<ArrowLeft />
{t('admin.users.backToUsers')}
</Link>
</Button>
</div>
<Card className="rounded-md">
<CardHeader>
<CardTitle>{t('admin.users.userDetails')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 items-center gap-4">
<Avatar className="h-14 w-14">
{user.image && <AvatarImage src={user.image} alt={displayName} />}
<AvatarFallback>{getInitials(displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate text-2xl font-semibold">{displayName}</h2>
<Badge variant={statusVariant}>{statusLabel}</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Mail className="h-4 w-4" />
{user.email}
</span>
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-4 w-4" />
{formatDate(user.createdAt)}
</span>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 md:min-w-[420px]">
<Metric label={t('admin.users.colRole')} value={roleLabel(user.role, t)} />
<Metric label={t('admin.users.colQuota')} value={quotaLabel} />
<Metric label={t('admin.users.activeEntitlements')} value={String(activeItems.length)} />
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-md">
<CardHeader>
<CardTitle>{t('admin.users.entitlements')}</CardTitle>
<CardAction>
<Button variant="outline" size="sm" onClick={() => setGrantOpen(true)} disabled={!user.orgId}>
<BadgeCent />
{t('admin.users.addEntitlement')}
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('admin.users.entitlementType')}</TableHead>
<TableHead>{t('admin.users.entitlementAmount')}</TableHead>
<TableHead>{t('admin.users.entitlementSource')}</TableHead>
<TableHead>{t('admin.users.entitlementExpires')}</TableHead>
<TableHead>{t('admin.users.entitlementStatus')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{formatEntitlementType(item.entitlementType, t)}</TableCell>
<TableCell className="font-medium tabular-nums">{formatSize(item.bytes)}</TableCell>
<TableCell className="max-w-[220px] truncate text-muted-foreground" title={item.sourceId}>
{formatSource(item.source, t)}
</TableCell>
<TableCell className="text-muted-foreground">
{item.expiresAt ? formatDate(item.expiresAt) : t('admin.users.noExpiry')}
</TableCell>
<TableCell>{formatStatus(item.status, t)}</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
{entitlementsQuery.isLoading ? t('common.loading') : t('admin.users.noEntitlements')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<GrantUserEntitlementDialog
open={grantOpen}
onOpenChange={setGrantOpen}
user={{ id: user.id, name: displayName }}
/>
</div>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 truncate text-sm font-medium">{value}</div>
</div>
)
}
function formatQuota(used: number, total: number): string {
if (total <= 0) return `${formatSize(used)} / --`
return `${formatSize(used)} / ${formatSize(total)}`
}
function formatDate(value: number | string): string {
const date = typeof value === 'number' ? new Date(value) : new Date(value)
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString()
}
function roleLabel(role: string | null, t: (key: string) => string): string {
return role === 'admin' ? t('admin.users.roleAdmin') : t('admin.users.roleMember')
}
function formatEntitlementType(type: string, t: (key: string) => string): string {
if (type === 'plan') return t('admin.users.entitlementTypePlan')
return t('admin.users.entitlementTypeGrant')
}
function formatSource(source: string, t: (key: string) => string): string {
if (source === 'admin_grant') return t('admin.users.entitlementSourceAdmin')
if (source === 'free_plan') return t('admin.users.entitlementSourceFreePlan')
if (source === 'cloud_order') return t('admin.users.entitlementSourceOrder')
return source
}
function formatStatus(status: string, t: (key: string) => string): string {
if (status === 'active') return t('admin.users.active')
if (status === 'revoked') return t('admin.users.revoked')
return status
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0].toUpperCase())
.join('')
}
+14 -78
View File
@@ -1,25 +1,17 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Search, Settings2, ShieldCheck, Trash2, UserCheck, UserPlus, UserX } from 'lucide-react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Search, ShieldCheck, Trash2, UserCheck, UserPlus, UserX } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DeleteUserDialog } from '@/components/admin/delete-user-dialog'
import { SiteInvitationsDialog } from '@/components/admin/site-invitations-dialog'
import { UserQuotaDialog } from '@/components/admin/user-quota-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
batchDeleteUsers,
batchUpdateUserQuota,
batchUpdateUserStatus,
listUsers,
type UserWithOrg,
updateUserStatus,
} from '@/lib/api'
import { batchDeleteUsers, batchUpdateUserStatus, listUsers, type UserWithOrg, updateUserStatus } from '@/lib/api'
import { formatSize } from '@/lib/format'
export const Route = createFileRoute('/_authenticated/admin/users/')({
@@ -33,13 +25,12 @@ const DEFAULT_PAGE_SIZE = 20
function UsersPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [quotaDialogUser, setQuotaDialogUser] = useState<UserRow | null>(null)
const [batchQuotaOpen, setBatchQuotaOpen] = useState(false)
const [deleteDialogUser, setDeleteDialogUser] = useState<{ id: string; name: string } | null>(null)
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
@@ -86,18 +77,6 @@ function UsersPage() {
},
})
const batchQuotaMutation = useMutation({
mutationFn: (quota: number) => batchUpdateUserQuota(selectedIds, quota),
onSuccess: (result) => {
setSelectedIds([])
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success(t('admin.users.batchQuotaUpdated', { count: result.updated }))
},
onError: (err) => {
toast.error(err.message)
},
})
const users: UserRow[] = useMemo(() => {
return usersQuery.data?.items ?? []
}, [usersQuery.data])
@@ -108,7 +87,7 @@ function UsersPage() {
const selectedCount = selectedIds.length
const pageUserIds = users.map((user) => user.id)
const allPageSelected = pageUserIds.length > 0 && pageUserIds.every((id) => selectedIds.includes(id))
const batchPending = batchStatusMutation.isPending || batchDeleteMutation.isPending || batchQuotaMutation.isPending
const batchPending = batchStatusMutation.isPending || batchDeleteMutation.isPending
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
setSearch(e.target.value)
@@ -194,10 +173,6 @@ function UsersPage() {
<UserCheck />
{t('admin.users.batchEnable')}
</Button>
<Button variant="outline" size="sm" disabled={batchPending} onClick={() => setBatchQuotaOpen(true)}>
<Settings2 />
{t('admin.users.batchSetQuota')}
</Button>
<Button variant="destructive" size="sm" disabled={batchPending} onClick={handleBatchDelete}>
<Trash2 />
{t('admin.users.batchDelete')}
@@ -241,7 +216,7 @@ function UsersPage() {
isToggling={toggleStatusMutation.isPending}
showQuota
onSelect={(checked) => toggleUserSelection(user.id, checked)}
onSetQuota={() => setQuotaDialogUser(user)}
onOpenUser={() => navigate({ to: '/admin/users/$userId', params: { userId: user.id } })}
onToggleStatus={() =>
toggleStatusMutation.mutate({
userId: user.id,
@@ -294,38 +269,6 @@ function UsersPage() {
)}
</div>
<UserQuotaDialog
open={quotaDialogUser !== null}
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
user={
quotaDialogUser?.orgId
? {
name: quotaDialogUser.name || quotaDialogUser.username,
orgId: quotaDialogUser.orgId,
quotaUsed: quotaDialogUser.quotaUsed,
quotaDefault: quotaDialogUser.quotaDefault,
}
: null
}
/>
<UserQuotaDialog
open={batchQuotaOpen}
onOpenChange={setBatchQuotaOpen}
user={
batchQuotaOpen
? {
name: t('admin.users.selectedUsers', { count: selectedCount }),
orgId: 'batch',
quotaUsed: 0,
quotaDefault: 0,
}
: null
}
onSave={(quota) => batchQuotaMutation.mutateAsync(quota)}
showSuccessToast={false}
/>
<DeleteUserDialog
open={deleteDialogUser !== null}
onOpenChange={(open) => !open && setDeleteDialogUser(null)}
@@ -343,7 +286,7 @@ function UserTableRow({
isToggling,
showQuota,
onSelect,
onSetQuota,
onOpenUser,
onToggleStatus,
onDelete,
}: {
@@ -352,7 +295,7 @@ function UserTableRow({
isToggling: boolean
showQuota: boolean
onSelect: (checked: boolean) => void
onSetQuota: () => void
onOpenUser: () => void
onToggleStatus: () => void
onDelete: () => void
}) {
@@ -376,7 +319,11 @@ function UserTableRow({
/>
</td>
<td className="px-4 py-3 font-medium">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className="flex min-w-0 items-center gap-3 text-left hover:text-primary"
onClick={onOpenUser}
>
<Avatar className="h-7 w-7 shrink-0">
{user.image && <AvatarImage src={user.image} alt={user.name || user.username} />}
<AvatarFallback className="text-xs">{getInitials(user.name || user.username || user.email)}</AvatarFallback>
@@ -384,7 +331,7 @@ function UserTableRow({
<span className="min-w-0 truncate" title={user.name || user.username}>
{user.name || user.username}
</span>
</div>
</button>
</td>
<td className="hidden truncate px-4 py-3 text-muted-foreground sm:table-cell" title={user.email}>
{user.email}
@@ -405,17 +352,6 @@ function UserTableRow({
</td>
<td className="whitespace-nowrap px-4 py-3">
<div className="flex items-center justify-end gap-1">
{showQuota && (
<Button
variant="ghost"
size="icon-xs"
disabled={!user.orgId}
onClick={onSetQuota}
title={t('admin.users.setQuota')}
>
<Settings2 />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
+156 -27
View File
@@ -190,6 +190,33 @@ function subscriptionPackage(): CloudProduct {
}
}
function higherSubscriptionPackage(): CloudProduct {
return {
...subscriptionPackage(),
id: 'pkg-business',
name: 'Business Plan',
metadata: {
deliverable: { type: 'zpan.plan', storageBytes: 214748364800, includedCredits: 5000 },
},
prices: [
{
id: 'price-business-usd',
currency: 'usd',
amount: 2999,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '5000' },
},
{
id: 'price-business-yearly-usd',
currency: 'usd',
amount: 29999,
recurring: { interval: 'year', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '5000' },
},
],
}
}
function creditPackage(): CloudProduct {
return {
id: 'pkg-credits',
@@ -274,7 +301,7 @@ describe('StoragePage', () => {
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] }))
})
it('shows effective quota and metered traffic status', async () => {
it('shows effective quota and plan credits status', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
baseQuota: 1024,
@@ -315,18 +342,13 @@ describe('StoragePage', () => {
await waitFor(() => expect(view.getByText('Team Plan')).toBeTruthy())
expect(view.getByText('storage.planActive')).toBeTruthy()
expect(view.getByText('storage.currentPlanDescription')).toBeTruthy()
expect(view.getByText('storage.effectiveStorageQuota')).toBeTruthy()
expect(view.getByText('storage.baseStorageQuota')).toBeTruthy()
expect(view.getByText('storage.cloudStorageEntitlement')).toBeTruthy()
expect(view.getByText('storage.creditBalance')).toBeTruthy()
expect(view.getByText('storage.trafficPolicy')).toBeTruthy()
expect(view.getByText('storage.usageBilledWithCredits')).toBeTruthy()
expect(view.getByText('storage.currentPeriodTraffic')).toBeTruthy()
await waitFor(() => expect(view.getByText('storage.storageQuotaDetail:1.5 KB/1.0 KB/512 B')).toBeTruthy())
expect(view.getByText('storage.storageUsage')).toBeTruthy()
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
expect(view.getAllByRole('progressbar')).toHaveLength(2)
expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy()
await waitFor(() => expect(view.getAllByText('1.5 KB').length).toBeGreaterThan(0))
expect(view.getByText('storage.trafficPeriodDetail:2026-05')).toBeTruthy()
expect(view.getByText('Team Plan · 1.0 KB')).toBeTruthy()
expect(view.getByText('Storage Pack · 512 B')).toBeTruthy()
await waitFor(() => expect(view.getAllByText('storage.overCap')).toHaveLength(2))
})
it('does not mark unlimited quota usage as over cap', async () => {
@@ -357,7 +379,7 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByText('storage.storageQuotaDetail:1.5 KB/0 B/0 B')).toBeTruthy())
await waitFor(() => expect(view.getAllByText('1.5 KB').length).toBeGreaterThan(0))
expect(view.queryByText('storage.overCap')).toBeNull()
expect(view.getAllByText('storage.usageNoLimit')).toHaveLength(2)
})
@@ -416,9 +438,8 @@ describe('StoragePage', () => {
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByText('storage.planBadge')).toBeTruthy())
expect(view.getByText('storage.trafficPolicy')).toBeTruthy()
expect(view.getByText('storage.includedCredits')).toBeTruthy()
expect(view.getByText('storage.usageBilledWithCredits')).toBeTruthy()
expect(view.queryByText('storage.trafficPolicy')).toBeNull()
})
it('uses the USD product price for checkout regardless of locale', async () => {
@@ -495,6 +516,16 @@ describe('StoragePage', () => {
storageExtraNames: [],
trafficPlanName: null,
trafficExtraNames: [],
currentPlan: {
sourceId: 'stripe_subscription:sub_1:org-1',
packageId: 'pkg-subscription',
name: 'Team Plan',
storageBytes: 107374182400,
trafficBytes: 0,
trafficOveragePriceCents: null,
expiresAt: null,
subscription: true,
},
})
vi.mocked(listCloudProducts).mockResolvedValue({ items: [subscriptionPackage()], total: 1 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
@@ -506,13 +537,55 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.managePlan' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.managePlan' }))
await waitFor(() => expect(view.getAllByRole('button', { name: 'storage.managePlan' }).length).toBeGreaterThan(0))
fireEvent.click(view.getAllByRole('button', { name: 'storage.managePlan' })[0])
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=portal')
})
it('shows only the active workspace plan when a subscription is active', async () => {
it('does not show manage plan for the free plan entitlement', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
baseQuota: 10485760,
entitlementQuota: 0,
quota: 10485760,
used: 0,
baseTrafficQuota: 0,
entitlementTrafficQuota: 0,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: '2026-05',
storagePlanName: 'Free',
storageExtraNames: [],
trafficPlanName: null,
trafficExtraNames: [],
currentPlan: {
sourceId: 'free_plan:org-1',
packageId: null,
name: 'Free',
storageBytes: 10485760,
trafficBytes: 0,
trafficOveragePriceCents: null,
expiresAt: null,
subscription: false,
},
})
vi.mocked(listCloudProducts).mockResolvedValue({ items: [subscriptionPackage()], total: 1 })
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByText('storage.freePlanName')).toBeTruthy())
expect(view.queryByRole('button', { name: 'storage.managePlan' })).toBeNull()
})
it('keeps available plans visible and marks the active plan card', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
baseQuota: 1024,
@@ -539,7 +612,10 @@ describe('StoragePage', () => {
subscription: true,
},
})
vi.mocked(listCloudProducts).mockResolvedValue({ items: [subscriptionPackage()], total: 1 })
vi.mocked(listCloudProducts).mockResolvedValue({
items: [subscriptionPackage(), higherSubscriptionPackage()],
total: 2,
})
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
@@ -550,13 +626,63 @@ describe('StoragePage', () => {
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.managePlan' })).toBeTruthy())
await waitFor(() => expect(view.getByText('storage.availablePlansTitle')).toBeTruthy())
expect(view.getByText('Team Plan')).toBeTruthy()
expect(view.getByRole('button', { name: 'storage.managePlan' })).toBeTruthy()
expect(view.getByText('Business Plan')).toBeTruthy()
await waitFor(() => expect(view.getByText('storage.currentPlanBadge')).toBeTruthy())
await waitFor(() => expect(view.getAllByRole('button', { name: 'storage.managePlan' }).length).toBeGreaterThan(0))
await waitFor(() => expect(view.getByRole('button', { name: 'storage.upgradeToPlan' })).toBeTruthy())
expect(view.queryByRole('button', { name: /storage.checkoutMonthly|storage.checkoutYearly/ })).toBeNull()
expect(openNewTab).not.toHaveBeenCalled()
})
it('opens the Stripe portal for higher plan changes while a plan is active', async () => {
vi.mocked(getUserQuota).mockResolvedValue({
orgId: 'org-1',
baseQuota: 1024,
entitlementQuota: 512,
quota: 1536,
used: 0,
baseTrafficQuota: 0,
entitlementTrafficQuota: 0,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: '2026-05',
storagePlanName: 'Team Plan',
storageExtraNames: [],
trafficPlanName: null,
trafficExtraNames: [],
currentPlan: {
sourceId: 'stripe_subscription:sub_1:org-1',
packageId: 'pkg-subscription',
name: 'Team Plan',
storageBytes: 107374182400,
trafficBytes: 0,
trafficOveragePriceCents: null,
expiresAt: null,
subscription: true,
},
})
vi.mocked(listCloudProducts).mockResolvedValue({
items: [subscriptionPackage(), higherSubscriptionPackage()],
total: 2,
})
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const view = renderStoragePage(queryClient)
await waitFor(() => expect(view.getByRole('button', { name: 'storage.upgradeToPlan' })).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'storage.upgradeToPlan' }))
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=portal')
})
it('uses the active workspace for orders and checkout', async () => {
activeOrganization.value = { id: 'org-2' }
vi.mocked(listCloudProducts).mockResolvedValue({ items: [quotaPackage()], total: 1 })
@@ -621,8 +747,8 @@ describe('StoragePage', () => {
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
await waitFor(() => expect(view.getByText('100 GB')).toBeTruthy())
expect(view.getByText('storage.availableProductsTitle')).toBeTruthy()
expect(view.getByText('storage.baseStorageQuota')).toBeTruthy()
expect(view.getByText('storage.availablePlansTitle')).toBeTruthy()
expect(view.getAllByText('storage.storageQuota').length).toBeGreaterThan(0)
expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy()
expect(view.queryByRole('button', { name: 'storage.redeemTitle' })).toBeNull()
})
@@ -645,11 +771,14 @@ describe('StoragePage', () => {
expect(creditsButton).toBeTruthy()
expect(creditsButton.textContent).toContain('storage.creditsButton')
expect(creditsButton.textContent).not.toContain('1,250')
expect(view.getByText('storage.currentPeriodTraffic')).toBeTruthy()
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
expect(view.queryByText('storage.creditBalance')).toBeNull()
expect(view.queryByText('1,250')).toBeNull()
fireEvent.click(creditsButton)
expect(await view.findByText('storage.creditBalance')).toBeTruthy()
await waitFor(() => expect(view.getAllByText('storage.creditBalance').length).toBeGreaterThan(0))
expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy()
await waitFor(() => expect(view.getByText('1,250')).toBeTruthy())
await waitFor(() => expect(view.getAllByText('1,250').length).toBeGreaterThan(0))
})
it('starts checkout from a credits top-up product', async () => {
@@ -714,7 +843,7 @@ describe('StoragePage', () => {
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
expect(await view.findByText('storage.creditActivityTitle')).toBeTruthy()
expect(view.getByText('1,250')).toBeTruthy()
expect(view.getAllByText('1,250').length).toBeGreaterThan(0)
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
})
+19 -19
View File
@@ -82,9 +82,7 @@ export function StoragePage() {
})
const currentOrders = ordersQuery.data?.items ?? []
const deliveredCheckoutCount = currentOrders.filter((order) => order.fulfillmentStatus === 'fulfilled').length
const hasActivePlan = Boolean(
quotaQuery.data?.currentPlan || quotaQuery.data?.storagePlanName || quotaQuery.data?.trafficPlanName,
)
const hasActiveSubscription = quotaQuery.data?.currentPlan?.subscription === true
const credits = creditsQuery.data ? { balance: creditsQuery.data.balance } : undefined
useEffect(() => {
@@ -205,23 +203,25 @@ export function StoragePage() {
</div>
)}
{hasActivePlan && quotaQuery.data ? (
<CurrentPlanCard
quota={quotaQuery.data}
creditsBalance={creditsQuery.data?.balance}
onManagePlan={managePlan}
isManagingPlan={false}
/>
) : (
<div className="space-y-6">
<FreeQuotaCard quota={quotaQuery.data} />
<StoragePackages
packages={cloudStoreQuery.data?.items ?? []}
disabled={!targetOrgId}
onCheckout={startCheckout}
<div className="space-y-6">
{hasActiveSubscription && quotaQuery.data ? (
<CurrentPlanCard
quota={quotaQuery.data}
creditsBalance={creditsQuery.data?.balance}
onManagePlan={managePlan}
isManagingPlan={false}
/>
</div>
)}
) : (
<FreeQuotaCard quota={quotaQuery.data} creditsBalance={creditsQuery.data?.balance} />
)}
<StoragePackages
packages={cloudStoreQuery.data?.items ?? []}
disabled={!targetOrgId}
currentPlan={quotaQuery.data?.currentPlan ?? null}
onCheckout={startCheckout}
onManagePlan={managePlan}
/>
</div>
<Dialog open={!!cancelOrderId} onOpenChange={(open) => !open && setCancelOrderId(null)}>
<DialogContent>
<DialogHeader>
+12 -33
View File
@@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Search, Settings2, ShieldCheck, Trash2, UserX } from 'lucide-react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Search, ShieldCheck, Trash2, UserX } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DeleteUserDialog } from '@/components/admin/delete-user-dialog'
import { UserQuotaDialog } from '@/components/admin/user-quota-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -20,12 +19,12 @@ type UserRow = UserWithOrg
function UsersPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const pageSize = 20
const [quotaDialogUser, setQuotaDialogUser] = useState<UserRow | null>(null)
const [deleteDialogUser, setDeleteDialogUser] = useState<{ id: string; name: string } | null>(null)
const usersQuery = useQuery({
@@ -106,7 +105,7 @@ function UsersPage() {
key={user.id}
user={user}
isToggling={toggleStatusMutation.isPending}
onSetQuota={() => setQuotaDialogUser(user)}
onOpenUser={() => navigate({ to: '/admin/users/$userId', params: { userId: user.id } })}
onToggleStatus={() =>
toggleStatusMutation.mutate({
userId: user.id,
@@ -141,21 +140,6 @@ function UsersPage() {
</div>
)}
<UserQuotaDialog
open={quotaDialogUser !== null}
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
user={
quotaDialogUser?.orgId
? {
name: quotaDialogUser.name,
orgId: quotaDialogUser.orgId,
quotaUsed: quotaDialogUser.quotaUsed,
quotaDefault: quotaDialogUser.quotaDefault,
}
: null
}
/>
<DeleteUserDialog
open={deleteDialogUser !== null}
onOpenChange={(open) => !open && setDeleteDialogUser(null)}
@@ -168,13 +152,13 @@ function UsersPage() {
function UserTableRow({
user,
isToggling,
onSetQuota,
onOpenUser,
onToggleStatus,
onDelete,
}: {
user: UserRow
isToggling: boolean
onSetQuota: () => void
onOpenUser: () => void
onToggleStatus: () => void
onDelete: () => void
}) {
@@ -191,7 +175,11 @@ function UserTableRow({
return (
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className="flex min-w-0 items-center gap-3 text-left hover:text-primary"
onClick={onOpenUser}
>
<Avatar className="h-7 w-7 shrink-0">
{user.image && <AvatarImage src={user.image} alt={user.name || user.username} />}
<AvatarFallback className="text-xs">{getInitials(user.name || user.username || user.email)}</AvatarFallback>
@@ -199,7 +187,7 @@ function UserTableRow({
<span className="min-w-0 truncate" title={user.name || user.username}>
{user.name || user.username}
</span>
</div>
</button>
</td>
<td className="truncate px-4 py-3 text-muted-foreground" title={user.email}>
{user.email}
@@ -218,15 +206,6 @@ function UserTableRow({
</td>
<td className="whitespace-nowrap px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-xs"
disabled={!user.orgId}
onClick={onSetQuota}
title={t('admin.users.setQuota')}
>
<Settings2 />
</Button>
<Button
variant="ghost"
size="icon-xs"