mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
feat: retroactive gate — per-team storage quota Pro-only (Z10) (#349)
- server/routes/quotas.ts: gate PUT /:orgId with requireFeature('team_quotas') → 402 on Community
- server/services/matter.ts: add teamQuotaEnabled param to incrementUsageIfAllowed and confirmUpload; when false, skip per-team quota check but still track storage usage
- server/services/save-to-drive.ts: thread teamQuotaEnabled through saveShareToDrive → saveFile/saveFolderRecursive
- server/routes/objects.ts: check hasFeature('team_quotas') before confirmUpload
- server/routes/shares.ts: skip isQuotaSufficient pre-check and pass teamQuotaEnabled to saveShareToDriveService when not Pro
- src/routes/_authenticated/admin/users/index.tsx: hide quota column/button behind useEntitlement('team_quotas'); show UpgradeHint when not Pro
- server/test/setup.ts: add seedProLicense helper for integration tests
- Update affected integration tests to seed Pro license where quota enforcement is expected
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { orgQuotas } from '../db/schema.js'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -206,6 +206,7 @@ describe('POST /api/objects/copy — quota enforcement', () => {
|
||||
describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via confirmUpload', () => {
|
||||
it('returns 200 and increments usage when quota allows', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
@@ -245,6 +246,7 @@ describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via con
|
||||
|
||||
it('returns 422 when confirming upload would exceed quota', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
patchMatterSchema,
|
||||
} from '../../shared/schemas'
|
||||
import type { Storage as S3Storage } from '../../shared/types'
|
||||
import { hasFeature, loadBindingState } from '../licensing/has-feature'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import {
|
||||
@@ -198,9 +199,11 @@ const app = new Hono<Env>()
|
||||
}
|
||||
case 'confirm': {
|
||||
try {
|
||||
const state = await loadBindingState(db)
|
||||
const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, {
|
||||
onConflict: body.onConflict,
|
||||
userId,
|
||||
teamQuotaEnabled: hasFeature('team_quotas', state),
|
||||
})
|
||||
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
|
||||
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
|
||||
// Sign up first user (gets promoted to admin via hook)
|
||||
@@ -48,6 +48,7 @@ describe('Admin Quotas API', () => {
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId creates quota for org', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
// Find the admin's personal org
|
||||
@@ -73,6 +74,7 @@ describe('Admin Quotas API', () => {
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId updates existing quota', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const orgs = await db.all<{ id: string }>(
|
||||
@@ -99,7 +101,8 @@ describe('Admin Quotas API', () => {
|
||||
})
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId rejects negative quota', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/quotas/some-org', {
|
||||
method: 'PUT',
|
||||
@@ -109,8 +112,29 @@ describe('Admin Quotas API', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT /api/admin/quotas/:orgId returns 402 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(402)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('feature_not_available')
|
||||
expect(body.feature).toBe('team_quotas')
|
||||
})
|
||||
|
||||
it('GET /api/admin/quotas lists quotas with org info', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
const orgs = await db.all<{ id: string }>(
|
||||
@@ -173,6 +197,7 @@ describe('User Quotas API — /api/quotas', () => {
|
||||
|
||||
it('GET /api/quotas/me returns quota after admin sets it', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const adminH = await adminHeaders(app)
|
||||
|
||||
// Find admin's org
|
||||
|
||||
@@ -7,6 +7,7 @@ import { organization } from '../db/auth-schema'
|
||||
import { orgQuotas } from '../db/schema'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { requireFeature } from '../middleware/require-feature'
|
||||
import { findPersonalOrg } from '../services/org'
|
||||
|
||||
const updateQuotaSchema = z.object({
|
||||
@@ -41,7 +42,7 @@ const adminQuotas = new Hono<Env>()
|
||||
|
||||
return c.json({ items, total: items.length })
|
||||
})
|
||||
.put('/:orgId', zValidator('json', updateQuotaSchema), async (c) => {
|
||||
.put('/:orgId', requireFeature('team_quotas'), zValidator('json', updateQuotaSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const orgId = c.req.param('orgId')
|
||||
const { quota } = c.req.valid('json')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { shareRecipients, shares } from '../db/schema.js'
|
||||
import * as emailService from '../services/email.js'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
@@ -628,6 +628,7 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
|
||||
it('returns 400 QUOTA_EXCEEDED when target org quota is exhausted', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
|
||||
+11
-4
@@ -8,6 +8,7 @@ import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema
|
||||
import type { Storage as S3Storage } from '../../shared/types'
|
||||
import { user } from '../db/auth-schema'
|
||||
import { matters } from '../db/schema'
|
||||
import { hasFeature, loadBindingState } from '../licensing/has-feature'
|
||||
import { requireAuth, requireTeamRole } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { listMatters } from '../services/matter'
|
||||
@@ -404,10 +405,15 @@ export const authedShares = new Hono<Env>()
|
||||
return c.json({ error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const totalBytes = await computeSourceBytes(db, matter)
|
||||
const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes)
|
||||
if (!quotaOk) {
|
||||
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400)
|
||||
const state = await loadBindingState(db)
|
||||
const teamQuotaEnabled = hasFeature('team_quotas', state)
|
||||
|
||||
if (teamQuotaEnabled) {
|
||||
const totalBytes = await computeSourceBytes(db, matter)
|
||||
const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes)
|
||||
if (!quotaOk) {
|
||||
return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await saveShareToDriveService(db, {
|
||||
@@ -416,6 +422,7 @@ export const authedShares = new Hono<Env>()
|
||||
currentUserId,
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
teamQuotaEnabled,
|
||||
})
|
||||
return c.json(result, 201)
|
||||
})
|
||||
|
||||
+20
-17
@@ -251,7 +251,7 @@ export async function confirmUpload(
|
||||
db: Database,
|
||||
id: string,
|
||||
orgId: string,
|
||||
opts: { onConflict?: ConflictStrategy; userId?: string } = {},
|
||||
opts: { onConflict?: ConflictStrategy; userId?: string; teamQuotaEnabled?: boolean } = {},
|
||||
): Promise<{ matter: Matter | null; quotaExceeded?: boolean }> {
|
||||
const existing = await getMatter(db, id, orgId)
|
||||
if (!existing) return { matter: null }
|
||||
@@ -270,7 +270,7 @@ export async function confirmUpload(
|
||||
|
||||
const bytes = existing.size ?? 0
|
||||
if (bytes > 0) {
|
||||
const allowed = await incrementUsageIfAllowed(db, orgId, existing.storageId, bytes)
|
||||
const allowed = await incrementUsageIfAllowed(db, orgId, existing.storageId, bytes, opts.teamQuotaEnabled ?? true)
|
||||
if (!allowed) return { matter: null, quotaExceeded: true }
|
||||
}
|
||||
|
||||
@@ -300,24 +300,27 @@ export async function incrementUsageIfAllowed(
|
||||
orgId: string,
|
||||
storageId: string,
|
||||
bytes: number,
|
||||
teamQuotaEnabled = true,
|
||||
): Promise<boolean> {
|
||||
// Check if a quota row exists and try atomic check-and-increment in one flow
|
||||
const rows = await db
|
||||
.select({ quota: orgQuotas.quota, used: orgQuotas.used })
|
||||
.from(orgQuotas)
|
||||
.where(eq(orgQuotas.orgId, orgId))
|
||||
|
||||
const [row] = rows
|
||||
if (row) {
|
||||
// quota=0 means unlimited; otherwise enforce the limit
|
||||
if (row.quota > 0 && row.used + bytes > row.quota) return false
|
||||
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
|
||||
if (teamQuotaEnabled) {
|
||||
// Check if a quota row exists and try atomic check-and-increment in one flow
|
||||
const rows = await db
|
||||
.select({ quota: orgQuotas.quota, used: orgQuotas.used })
|
||||
.from(orgQuotas)
|
||||
.where(eq(orgQuotas.orgId, orgId))
|
||||
|
||||
const [row] = rows
|
||||
if (row) {
|
||||
// quota=0 means unlimited; otherwise enforce the limit
|
||||
if (row.quota > 0 && row.used + bytes > row.quota) return false
|
||||
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
.set({ used: sql`${orgQuotas.used} + ${bytes}` })
|
||||
.where(eq(orgQuotas.orgId, orgId))
|
||||
}
|
||||
// No quota row means no org-level limit — allow, but still track per-storage usage
|
||||
}
|
||||
// No quota row means no org-level limit — allow, but still track per-storage usage
|
||||
|
||||
await db
|
||||
.update(storages)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { activityEvents, matters, orgQuotas, shares } from '../db/schema'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
import { computeSourceBytes, isQuotaSufficient, saveShareToDrive } from './save-to-drive.js'
|
||||
import { createShare, resolveShareByToken } from './share.js'
|
||||
|
||||
@@ -604,6 +604,7 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
|
||||
it('returns 400 QUOTA_EXCEEDED when target org has insufficient quota', async () => {
|
||||
const { app, db, share, headers } = await setup()
|
||||
await seedProLicense(db)
|
||||
|
||||
// Get the current user's ID to add them as editor in the quota-restricted org
|
||||
const sessionRes = await app.request('/api/auth/get-session', { headers: new Headers(headers) })
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SaveShareInput {
|
||||
currentUserId: string
|
||||
targetOrgId: string
|
||||
targetParent: string
|
||||
teamQuotaEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface SaveShareResult {
|
||||
@@ -90,11 +91,12 @@ async function saveFile(
|
||||
targetOrgId: string,
|
||||
targetParent: string,
|
||||
shareId: string,
|
||||
teamQuotaEnabled = true,
|
||||
): Promise<Matter> {
|
||||
const bytes = sourceMatter.size ?? 0
|
||||
|
||||
if (bytes > 0) {
|
||||
const allowed = await incrementUsageIfAllowed(db, targetOrgId, targetStorage.id, bytes)
|
||||
const allowed = await incrementUsageIfAllowed(db, targetOrgId, targetStorage.id, bytes, teamQuotaEnabled)
|
||||
if (!allowed) throw new Error('QUOTA_EXCEEDED')
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ async function saveFolderRecursive(
|
||||
targetOrgId: string,
|
||||
targetParent: string,
|
||||
shareId: string,
|
||||
teamQuotaEnabled = true,
|
||||
): Promise<SaveShareResult> {
|
||||
const saved: Matter[] = []
|
||||
const skipped: Array<{ name: string; reason: string }> = []
|
||||
@@ -185,6 +188,7 @@ async function saveFolderRecursive(
|
||||
targetOrgId,
|
||||
targetPath,
|
||||
shareId,
|
||||
teamQuotaEnabled,
|
||||
)
|
||||
saved.push(newFile)
|
||||
} catch (e) {
|
||||
@@ -218,7 +222,7 @@ async function saveFolderRecursive(
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveShareToDrive(db: Database, input: SaveShareInput): Promise<SaveShareResult> {
|
||||
const { share, matter: sourceMatter, currentUserId, targetOrgId, targetParent } = input
|
||||
const { share, matter: sourceMatter, currentUserId, targetOrgId, targetParent, teamQuotaEnabled = true } = input
|
||||
|
||||
const sourceStorage = await getStorage(db, sourceMatter.storageId)
|
||||
if (!sourceStorage) throw new Error('Source storage not found')
|
||||
@@ -229,9 +233,29 @@ export async function saveShareToDrive(db: Database, input: SaveShareInput): Pro
|
||||
const dst = targetStorage as unknown as S3StorageType
|
||||
|
||||
if (sourceMatter.dirtype === DirType.FILE) {
|
||||
const newMatter = await saveFile(db, sourceMatter, src, dst, currentUserId, targetOrgId, targetParent, share.id)
|
||||
const newMatter = await saveFile(
|
||||
db,
|
||||
sourceMatter,
|
||||
src,
|
||||
dst,
|
||||
currentUserId,
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
share.id,
|
||||
teamQuotaEnabled,
|
||||
)
|
||||
return { saved: [newMatter], skipped: [] }
|
||||
}
|
||||
|
||||
return saveFolderRecursive(db, sourceMatter, src, dst, currentUserId, targetOrgId, targetParent, share.id)
|
||||
return saveFolderRecursive(
|
||||
db,
|
||||
sourceMatter,
|
||||
src,
|
||||
dst,
|
||||
currentUserId,
|
||||
targetOrgId,
|
||||
targetParent,
|
||||
share.id,
|
||||
teamQuotaEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { createApp } from '../app'
|
||||
import { createAuth } from '../auth'
|
||||
@@ -318,3 +319,19 @@ export async function authedHeaders(
|
||||
const cookies = signUpRes.headers.getSetCookie()
|
||||
return { Cookie: cookies.join('; ') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a Pro license binding row so that feature gates resolve as enabled.
|
||||
* Pass specific features to restrict which Pro features are active; defaults
|
||||
* to all four.
|
||||
*/
|
||||
export async function seedProLicense(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
features: string[] = ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'],
|
||||
) {
|
||||
const cert = JSON.stringify({ plan: 'pro', features })
|
||||
await db.run(sql`
|
||||
INSERT OR REPLACE INTO license_binding (id, instance_id, refresh_token, cached_cert, bound_at)
|
||||
VALUES (1, 'test-instance', 'test-refresh-token', ${cert}, ${Date.now()})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ 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 { UpgradeHint } from '@/components/UpgradeHint'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useEntitlement } from '@/hooks/useEntitlement'
|
||||
import { listQuotas, listUsers, type QuotaItem, type UserWithOrg, updateUserStatus } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/users/')({
|
||||
@@ -22,6 +24,8 @@ interface UserRow extends UserWithOrg {
|
||||
function UsersPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { hasFeature } = useEntitlement()
|
||||
const teamQuotasEnabled = hasFeature('team_quotas')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
@@ -37,6 +41,7 @@ function UsersPage() {
|
||||
const quotasQuery = useQuery({
|
||||
queryKey: ['admin', 'quotas'],
|
||||
queryFn: listQuotas,
|
||||
enabled: teamQuotasEnabled,
|
||||
})
|
||||
|
||||
const toggleStatusMutation = useMutation({
|
||||
@@ -77,7 +82,7 @@ function UsersPage() {
|
||||
|
||||
const total = usersQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const isLoading = usersQuery.isLoading || quotasQuery.isLoading
|
||||
const isLoading = usersQuery.isLoading || (teamQuotasEnabled && quotasQuery.isLoading)
|
||||
|
||||
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setSearch(e.target.value)
|
||||
@@ -115,7 +120,9 @@ function UsersPage() {
|
||||
<th className="hidden px-4 py-3 text-left font-medium sm:table-cell">{t('admin.users.colEmail')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colRole')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.users.colStatus')}</th>
|
||||
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">{t('admin.users.colQuota')}</th>
|
||||
{teamQuotasEnabled && (
|
||||
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">{t('admin.users.colQuota')}</th>
|
||||
)}
|
||||
<th className="hidden px-4 py-3 text-left font-medium lg:table-cell">{t('admin.users.colCreatedAt')}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t('admin.users.colActions')}</th>
|
||||
</tr>
|
||||
@@ -126,6 +133,7 @@ function UsersPage() {
|
||||
key={user.id}
|
||||
user={user}
|
||||
isToggling={toggleStatusMutation.isPending}
|
||||
showQuota={teamQuotasEnabled}
|
||||
onSetQuota={() => setQuotaDialogUser(user)}
|
||||
onToggleStatus={() =>
|
||||
toggleStatusMutation.mutate({
|
||||
@@ -138,7 +146,7 @@ function UsersPage() {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<td colSpan={teamQuotasEnabled ? 7 : 6} className="px-4 py-8 text-center text-muted-foreground">
|
||||
{t('admin.users.noUsers')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -161,20 +169,24 @@ 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,
|
||||
quotaTotal: quotaDialogUser.quotaTotal,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
{!teamQuotasEnabled && <UpgradeHint feature="team_quotas" />}
|
||||
|
||||
{teamQuotasEnabled && (
|
||||
<UserQuotaDialog
|
||||
open={quotaDialogUser !== null}
|
||||
onOpenChange={(open) => !open && setQuotaDialogUser(null)}
|
||||
user={
|
||||
quotaDialogUser?.orgId
|
||||
? {
|
||||
name: quotaDialogUser.name || quotaDialogUser.username,
|
||||
orgId: quotaDialogUser.orgId,
|
||||
quotaUsed: quotaDialogUser.quotaUsed,
|
||||
quotaTotal: quotaDialogUser.quotaTotal,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteUserDialog
|
||||
open={deleteDialogUser !== null}
|
||||
@@ -188,12 +200,14 @@ function UsersPage() {
|
||||
function UserTableRow({
|
||||
user,
|
||||
isToggling,
|
||||
showQuota,
|
||||
onSetQuota,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: {
|
||||
user: UserRow
|
||||
isToggling: boolean
|
||||
showQuota: boolean
|
||||
onSetQuota: () => void
|
||||
onToggleStatus: () => void
|
||||
onDelete: () => void
|
||||
@@ -220,19 +234,21 @@ function UserTableRow({
|
||||
{user.banned ? t('admin.users.disabled') : t('admin.users.active')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-muted-foreground md:table-cell">{quotaLabel}</td>
|
||||
{showQuota && <td className="hidden px-4 py-3 text-muted-foreground md:table-cell">{quotaLabel}</td>}
|
||||
<td className="hidden px-4 py-3 text-muted-foreground lg:table-cell">{formatDate(user.createdAt)}</td>
|
||||
<td className="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>
|
||||
{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"
|
||||
|
||||
Reference in New Issue
Block a user