mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(quota): consolidate storage entitlements
This commit is contained in:
@@ -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
@@ -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,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
@@ -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> {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "暂无存储订单。"
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user