diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index bf7e8141..ff185943 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -462,11 +462,14 @@ describe('createPersonalOrg — org name and quota edge cases', () => { expect(res.status).toBe(200) }) - it('sign-up with default_org_quota set to zero does not insert org_quota row', async () => { + it('sign-up with default_org_quota set to zero falls back to DEFAULT_ORG_QUOTA', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' }) const res = await signUp(ctx, 'zero-quota@example.com') expect(res.status).toBe(200) + const quotas = await ctx.db.select().from(schema.orgQuotas) + expect(quotas).toHaveLength(1) + expect(quotas[0].quota).toBe(10485760) }) }) diff --git a/server/auth.ts b/server/auth.ts index 490ed7c4..b34596f1 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -6,7 +6,7 @@ import { genericOAuth } from 'better-auth/plugins/generic-oauth' import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access' import { count, eq, like } from 'drizzle-orm' import { customAlphabet, nanoid } from 'nanoid' -import { SignupMode } from '../shared/constants' +import { DEFAULT_ORG_QUOTA, SignupMode } from '../shared/constants' import { BUILTIN_PROVIDER_IDS, OAUTH_PROVIDER_KEY_PATTERN, @@ -459,15 +459,11 @@ async function createPersonalOrg( createdAt: now, }) - if (defaultQuota > 0) { - await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota: defaultQuota, used: 0 }) - } + await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota: defaultQuota, used: 0 }) return orgId } -const DEFAULT_ORG_QUOTA = 10 * 1024 * 1024 // 10 MB - async function getDefaultOrgQuota(db: Database): Promise { const rows = await db .select({ value: systemOptions.value }) @@ -476,5 +472,5 @@ async function getDefaultOrgQuota(db: Database): Promise { const raw = rows[0]?.value if (raw == null) return DEFAULT_ORG_QUOTA const n = Number(raw) - return Number.isFinite(n) ? n : DEFAULT_ORG_QUOTA + return Number.isFinite(n) && n > 0 ? n : DEFAULT_ORG_QUOTA } diff --git a/server/routes/auth.integration.test.ts b/server/routes/auth.integration.test.ts index 73aae818..d31d2524 100644 --- a/server/routes/auth.integration.test.ts +++ b/server/routes/auth.integration.test.ts @@ -196,16 +196,22 @@ describe('Auth API', () => { expect(rows[0].used).toBe(0) }) - it('signup with default_org_quota set to 0 does NOT create an org_quotas row', async () => { + it('signup with default_org_quota set to 0 creates a built-in default org_quotas row', async () => { const { app, db } = await createTestApp() await db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' }) - await app.request('/api/auth/sign-up/email', { + const signUpRes = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Test', email: 'quota-zero@example.com', password: 'password123456' }), }) - const rows = await db.select().from(schema.orgQuotas) - expect(rows).toHaveLength(0) + const body = (await signUpRes.json()) as { user: { id: string } } + const orgs = await db + .select() + .from(authSchema.organization) + .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) }) it('sign-in with a malformed stored password hash returns a non-200 error response', async () => { diff --git a/server/routes/quotas.integration.test.ts b/server/routes/quotas.integration.test.ts index 9dc0c2ae..37349388 100644 --- a/server/routes/quotas.integration.test.ts +++ b/server/routes/quotas.integration.test.ts @@ -109,6 +109,28 @@ describe('Admin Quotas API', () => { 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 works without Pro license', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) diff --git a/server/routes/quotas.ts b/server/routes/quotas.ts index 9a5b8dea..0e0f3f20 100644 --- a/server/routes/quotas.ts +++ b/server/routes/quotas.ts @@ -11,7 +11,7 @@ import { recordActivity } from '../services/activity' import { findPersonalOrg } from '../services/org' const updateQuotaSchema = z.object({ - quota: z.number().min(0), + quota: z.number().int().positive(), }) const adminQuotas = new Hono() diff --git a/server/routes/system.integration.test.ts b/server/routes/system.integration.test.ts index d76324e5..32435d5e 100644 --- a/server/routes/system.integration.test.ts +++ b/server/routes/system.integration.test.ts @@ -83,4 +83,14 @@ describe('System API — options CRUD', () => { const del = await app.request('/api/system/options/site_name', { method: 'DELETE' }) expect(del.status).toBe(401) }) + + it('rejects invalid default organization quota values', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + for (const value of ['0', '-1', '1.5', 'abc']) { + const res = await putOption(app, admin, 'default_org_quota', { value }) + expect(res.status).toBe(400) + } + }) }) diff --git a/server/routes/system.ts b/server/routes/system.ts index dc8fc47d..b4c9cb80 100644 --- a/server/routes/system.ts +++ b/server/routes/system.ts @@ -52,6 +52,13 @@ const app = new Hono() } } + if (key === 'default_org_quota') { + const quota = Number(body.value) + if (!Number.isInteger(quota) || quota <= 0) { + return c.json({ error: 'Default organization quota must be a positive number' }, 400) + } + } + const existing = await db .select({ key: systemOptions.key, public: systemOptions.public }) .from(systemOptions) diff --git a/server/routes/users.integration.test.ts b/server/routes/users.integration.test.ts index 1f1ef069..5840cb9c 100644 --- a/server/routes/users.integration.test.ts +++ b/server/routes/users.integration.test.ts @@ -14,11 +14,11 @@ async function adminHeaders(app: ReturnType, email: string) { +async function signUpUser(app: ReturnType, email: string, name = 'Other User') { const res = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Other User', email, password: 'password123456' }), + body: JSON.stringify({ name, email, password: 'password123456' }), }) return res.json() } @@ -59,6 +59,30 @@ describe('Admin Users API', () => { expect(body.items[0].orgName).toBeTruthy() }) + it('GET /api/admin/users filters by name, username, or email with filtered totals', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + await signUpUser(app, 'match-email@example.com', 'Email Match') + await signUpUser(app, 'username-target@example.com', 'Plain Name') + await signUpUser(app, 'other@example.com', 'Other Person') + + const byName = await app.request('/api/admin/users?search=email%20match', { headers }) + expect(byName.status).toBe(200) + const byNameBody = (await byName.json()) as { items: Array>; total: number } + expect(byNameBody.total).toBe(1) + expect(byNameBody.items[0].email).toBe('match-email@example.com') + + const byUsername = await app.request('/api/admin/users?search=username-target', { headers }) + const byUsernameBody = (await byUsername.json()) as { items: Array>; total: number } + expect(byUsernameBody.total).toBe(1) + expect(byUsernameBody.items[0].email).toBe('username-target@example.com') + + const byEmail = await app.request('/api/admin/users?search=other@example.com', { headers }) + const byEmailBody = (await byEmail.json()) as { items: Array>; total: number } + expect(byEmailBody.total).toBe(1) + expect(byEmailBody.items[0].email).toBe('other@example.com') + }) + it('PATCH /api/admin/users/:id disables a user', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -160,4 +184,160 @@ describe('Admin Users API', () => { }) expect(res.status).toBe(404) }) + + it('PATCH /api/admin/users/batch disables and enables users', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await signUpUser(app, 'batch1@example.com') + await signUpUser(app, 'batch2@example.com') + const users = await db.all<{ id: string }>( + sql`SELECT id FROM user WHERE email IN ('batch1@example.com', 'batch2@example.com') ORDER BY email`, + ) + const ids = users.map((row) => row.id) + + const disable = await app.request('/api/admin/users/batch', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'disable', ids }), + }) + expect(disable.status).toBe(200) + expect((await disable.json()) as Record).toMatchObject({ updated: 2, ids, status: 'disabled' }) + const disabled = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id IN (${ids[0]}, ${ids[1]})`) + expect(disabled.every((row) => row.banned === 1)).toBe(true) + + const enable = await app.request('/api/admin/users/batch', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'enable', ids }), + }) + expect(enable.status).toBe(200) + const enabled = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id IN (${ids[0]}, ${ids[1]})`) + expect(enabled.every((row) => row.banned === 0)).toBe(true) + }) + + it('PATCH /api/admin/users/batch sets quota for personal orgs', 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'`) + 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', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 654321 }), + }) + + 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) + }) + + it('PATCH /api/admin/users/batch 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') + const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'no-personal-org@example.com'`) + 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', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_quota', ids: [userId], quota: 123456 }), + }) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: `Personal organization not found for user(s): ${userId}` }) + }) + + it('DELETE /api/admin/users/batch deletes selected users', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await signUpUser(app, 'delete1@example.com') + await signUpUser(app, 'delete2@example.com') + const users = await db.all<{ id: string }>( + sql`SELECT id FROM user WHERE email IN ('delete1@example.com', 'delete2@example.com') ORDER BY email`, + ) + const ids = users.map((row) => row.id) + + const res = await app.request('/api/admin/users/batch', { + method: 'DELETE', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + + expect(res.status).toBe(200) + expect((await res.json()) as Record).toMatchObject({ deleted: 2, ids }) + const remaining = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE id IN (${ids[0]}, ${ids[1]})`) + expect(remaining).toHaveLength(0) + }) + + it('batch operations reject missing users instead of skipping them', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + const patch = await app.request('/api/admin/users/batch', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'disable', ids: ['missing-user'] }), + }) + 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' }, + body: JSON.stringify({ ids: ['missing-user'] }), + }) + expect(del.status).toBe(404) + expect(await del.json()).toEqual({ error: 'User not found: missing-user' }) + }) + + it('PATCH /api/admin/users/batch rejects non-positive quota values', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + const res = await app.request('/api/admin/users/batch', { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set_quota', ids: ['some-user'], quota: 0 }), + }) + expect(res.status).toBe(400) + }) }) diff --git a/server/routes/users.ts b/server/routes/users.ts index 699680de..5aa46569 100644 --- a/server/routes/users.ts +++ b/server/routes/users.ts @@ -4,20 +4,98 @@ import { z } from 'zod' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { recordActivity } from '../services/activity' -import { deleteUser, listUsers, setUserStatus } from '../services/user' +import { + deleteUser, + deleteUsers, + listUsers, + setUserStatus, + setUsersPersonalQuota, + setUsersStatus, +} from '../services/user' const updateStatusSchema = z.object({ status: z.enum(['active', 'disabled']), }) +const userIdsSchema = z.object({ + ids: z.array(z.string().min(1)).min(1), +}) + +const batchPatchSchema = z.discriminatedUnion('action', [ + z.object({ + 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 app = new Hono() .use(requireAdmin) .get('/', async (c) => { const db = c.get('platform').db const page = Math.max(1, Number(c.req.query('page') ?? '1')) const pageSize = Math.min(100, Math.max(1, Number(c.req.query('pageSize') ?? '20'))) + const search = c.req.query('search') - const result = await listUsers(db, page, pageSize) + const result = await listUsers(db, page, pageSize, search) + 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) + + await recordActivity(db, { + orgId, + userId: adminUserId, + action: status === 'disabled' ? 'user_disable' : 'user_enable', + targetType: 'user', + targetName: 'batch', + metadata: { ...result, status }, + }) + return c.json({ ...result, status }) + }) + .delete('/batch', zValidator('json', userIdsSchema), async (c) => { + const db = c.get('platform').db + const adminUserId = c.get('userId')! + const orgId = c.get('orgId')! + const { ids } = c.req.valid('json') + + const result = await deleteUsers(db, ids) + if ('error' in result) return c.json({ error: result.error }, result.status) + + await recordActivity(db, { + orgId, + userId: adminUserId, + action: 'user_delete', + targetType: 'user', + targetName: 'batch', + metadata: result, + }) return c.json(result) }) .patch('/:id', zValidator('json', updateStatusSchema), async (c) => { diff --git a/server/services/user.ts b/server/services/user.ts index 56964c45..ab6bd885 100644 --- a/server/services/user.ts +++ b/server/services/user.ts @@ -1,5 +1,7 @@ -import { and, count, desc, eq, sql } from 'drizzle-orm' +import { and, count, desc, eq, inArray, or, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' import { member, organization, user } from '../db/auth-schema' +import { orgQuotas } from '../db/schema' import type { Database } from '../platform/interface' export interface UserWithOrg { @@ -14,17 +16,33 @@ export interface UserWithOrg { orgName: string | null } +export interface UserOperationFailure { + error: string + status: 404 +} + export async function listUsers( db: Database, page: number, pageSize: number, + search?: string, ): Promise<{ items: UserWithOrg[]; total: number }> { const offset = (page - 1) * pageSize + const term = search?.trim().toLowerCase() + const filter = term + ? or( + sql`lower(${user.name}) like ${`%${term}%`}`, + sql`lower(${user.username}) like ${`%${term}%`}`, + sql`lower(${user.email}) like ${`%${term}%`}`, + ) + : undefined - const countRows = await db.select({ total: count() }).from(user) + const countRows = filter + ? await db.select({ total: count() }).from(user).where(filter) + : await db.select({ total: count() }).from(user) const total = countRows[0]?.total ?? 0 - const rows = await db + const query = db .select({ id: user.id, name: user.name, @@ -42,6 +60,8 @@ export async function listUsers( organization, and(eq(organization.id, member.organizationId), eq(organization.slug, sql`'personal-' || ${user.id}`)), ) + + const rows = await (filter ? query.where(filter) : query) .groupBy(user.id) .orderBy(desc(user.createdAt)) .limit(pageSize) @@ -73,3 +93,86 @@ export async function deleteUser(db: Database, userId: string): Promise await db.delete(user).where(eq(user.id, userId)) return true } + +export async function setUsersStatus( + db: Database, + userIds: string[], + status: 'active' | 'disabled', +): Promise<{ updated: number; ids: string[] } | UserOperationFailure> { + const existingIds = await requireUsers(db, userIds) + if ('error' in existingIds) return existingIds + + await db + .update(user) + .set({ banned: status === 'disabled' }) + .where(inArray(user.id, existingIds)) + return { updated: existingIds.length, ids: existingIds } +} + +export async function deleteUsers( + db: Database, + userIds: string[], +): Promise<{ deleted: number; ids: string[] } | UserOperationFailure> { + const existingIds = await requireUsers(db, userIds) + if ('error' in existingIds) return existingIds + + await db.delete(user).where(inArray(user.id, existingIds)) + return { deleted: existingIds.length, ids: existingIds } +} + +export async function setUsersPersonalQuota( + 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 + + const rows = await db + .select({ userId: user.id, 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)) + + if (existingOrgIds.size > 0) { + await db + .update(orgQuotas) + .set({ quota }) + .where(inArray(orgQuotas.orgId, [...existingOrgIds])) + } + + for (const orgId of nowMissing) { + await db.insert(orgQuotas).values({ id: nanoid(), orgId, quota, used: 0 }) + } + + return { updated: rows.length, userIds: existingIds, orgIds, quota } +} + +async function requireUsers(db: Database, userIds: string[]): Promise { + const uniqueIds = [...new Set(userIds)] + const rows = await db.select({ id: user.id }).from(user).where(inArray(user.id, uniqueIds)) + if (rows.length !== uniqueIds.length) { + const found = new Set(rows.map((row) => row.id)) + const missing = uniqueIds.filter((id) => !found.has(id)) + return { error: `User not found: ${missing.join(', ')}`, status: 404 } + } + return uniqueIds +} diff --git a/shared/constants.ts b/shared/constants.ts index 37102143..36376c49 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -46,6 +46,7 @@ export type SignupMode = (typeof SignupMode)[keyof typeof SignupMode] export const ZPAN_CLOUD_URL_DEFAULT = 'https://cloud.zpan.space' export const DEFAULT_SITE_NAME = 'ZPan' export const DEFAULT_SITE_DESCRIPTION = '' +export const DEFAULT_ORG_QUOTA = 10 * 1024 * 1024 // Free plan allows up to this many organizations per user (including personal workspace). // The 3rd organization requires the teams_unlimited feature. diff --git a/src/components/admin/user-quota-dialog.test.tsx b/src/components/admin/user-quota-dialog.test.tsx new file mode 100644 index 00000000..23166fef --- /dev/null +++ b/src/components/admin/user-quota-dialog.test.tsx @@ -0,0 +1,107 @@ +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 ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, type, ...props }: React.ButtonHTMLAttributes & { variant?: string }) => ( + + ), +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => ( + + ), +})) + +const user = { + name: 'Test User', + orgId: 'org-1', + quotaUsed: 512, + quotaTotal: 2 * 1024 * 1024 * 1024, +} + +function renderDialog(props: Partial> = {}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('UserQuotaDialog', () => { + 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() + }) +}) diff --git a/src/components/admin/user-quota-dialog.tsx b/src/components/admin/user-quota-dialog.tsx index de293be1..4982bad4 100644 --- a/src/components/admin/user-quota-dialog.tsx +++ b/src/components/admin/user-quota-dialog.tsx @@ -19,11 +19,13 @@ interface UserQuotaDialogProps { open: boolean onOpenChange: (open: boolean) => void user: { name: string; orgId: string; quotaUsed: number; quotaTotal: number } | null + onSave?: (quota: number) => Promise + showSuccessToast?: boolean } const BYTES_PER_GB = 1024 * 1024 * 1024 -export function UserQuotaDialog({ open, onOpenChange, user }: UserQuotaDialogProps) { +export function UserQuotaDialog({ open, onOpenChange, user, onSave, showSuccessToast = true }: UserQuotaDialogProps) { const { t } = useTranslation() const queryClient = useQueryClient() const [quotaGB, setQuotaGB] = useState('') @@ -36,12 +38,12 @@ export function UserQuotaDialog({ open, onOpenChange, user }: UserQuotaDialogPro }, [open, user]) const mutation = useMutation({ - mutationFn: ({ orgId, quota }: { orgId: string; quota: number }) => updateQuota(orgId, quota), + 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) - toast.success(t('admin.users.quotaUpdated')) + if (showSuccessToast) toast.success(t('admin.users.quotaUpdated')) }, onError: (err) => { toast.error(err.message) @@ -57,7 +59,10 @@ export function UserQuotaDialog({ open, onOpenChange, user }: UserQuotaDialogPro e.preventDefault() if (!user) return const value = Number(quotaGB) - if (Number.isNaN(value) || value < 0) return + if (!Number.isFinite(value) || value <= 0) { + toast.error(t('admin.users.positiveQuotaRequired')) + return + } mutation.mutate({ orgId: user.orgId, quota: Math.round(value * BYTES_PER_GB) }) } @@ -78,13 +83,14 @@ export function UserQuotaDialog({ open, onOpenChange, user }: UserQuotaDialogPro setQuotaGB(e.target.value)} placeholder="10" required /> +

{t('admin.users.positiveQuotaHint')}

+ @@ -195,8 +235,11 @@ function SettingsPage() { form.setValue('registrationsEnabled', checked, { shouldDirty: true })} + disabled={!hasOpenRegistration || registrationMutation.isPending} + onCheckedChange={(checked) => { + form.setValue('registrationsEnabled', checked, { shouldDirty: true }) + registrationMutation.mutate(checked) + }} /> @@ -221,7 +264,7 @@ function SettingsPage() {

- {quotaDisplayBytes === 0 ? t('admin.settings.unlimited') : formatSize(quotaDisplayBytes)} + {Number.isFinite(quotaDisplayBytes) && quotaDisplayBytes > 0 ? formatSize(quotaDisplayBytes) : '--'}

{t('admin.settings.defaultOrgQuotaHint')} @@ -250,8 +293,8 @@ function SettingsPage() {

-
diff --git a/src/routes/_authenticated/admin/users/index.tsx b/src/routes/_authenticated/admin/users/index.tsx index 55975f51..9800dc08 100644 --- a/src/routes/_authenticated/admin/users/index.tsx +++ b/src/routes/_authenticated/admin/users/index.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { Search, Settings2, ShieldCheck, Trash2, UserPlus, UserX } from 'lucide-react' +import { Search, Settings2, ShieldCheck, Trash2, UserCheck, UserPlus, UserX } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -8,8 +8,18 @@ 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 { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' -import { listQuotas, listUsers, type QuotaItem, type UserWithOrg, updateUserStatus } from '@/lib/api' +import { + batchDeleteUsers, + batchUpdateUserQuota, + batchUpdateUserStatus, + listQuotas, + listUsers, + type QuotaItem, + type UserWithOrg, + updateUserStatus, +} from '@/lib/api' export const Route = createFileRoute('/_authenticated/admin/users/')({ component: UsersPage, @@ -28,12 +38,14 @@ function UsersPage() { const pageSize = 20 const [quotaDialogUser, setQuotaDialogUser] = useState(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([]) const usersQuery = useQuery({ - queryKey: ['admin', 'users', page, pageSize], - queryFn: () => listUsers(page, pageSize), + queryKey: ['admin', 'users', page, pageSize, search], + queryFn: () => listUsers(page, pageSize, search), }) const quotasQuery = useQuery({ @@ -53,6 +65,45 @@ function UsersPage() { }, }) + const batchStatusMutation = useMutation({ + mutationFn: ({ ids, status }: { ids: string[]; status: 'active' | 'disabled' }) => + batchUpdateUserStatus(ids, status), + onSuccess: (result) => { + setSelectedIds([]) + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + toast.success(t('admin.users.batchStatusUpdated', { count: result.updated })) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + const batchDeleteMutation = useMutation({ + mutationFn: (ids: string[]) => batchDeleteUsers(ids), + onSuccess: (result) => { + setSelectedIds([]) + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'quotas'] }) + toast.success(t('admin.users.batchDeleted', { count: result.deleted })) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + const batchQuotaMutation = useMutation({ + mutationFn: (quota: number) => batchUpdateUserQuota(selectedIds, quota), + onSuccess: (result) => { + setSelectedIds([]) + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'quotas'] }) + toast.success(t('admin.users.batchQuotaUpdated', { count: result.updated })) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + const quotaMap = useMemo(() => { const map = new Map() for (const q of quotasQuery.data?.items ?? []) { @@ -69,21 +120,39 @@ function UsersPage() { }) }, [usersQuery.data, quotaMap]) - const filtered = useMemo(() => { - if (!search.trim()) return users - const term = search.toLowerCase() - return users.filter( - (u) => (u.name || u.username).toLowerCase().includes(term) || u.email.toLowerCase().includes(term), - ) - }, [users, search]) - const total = usersQuery.data?.total ?? 0 const totalPages = Math.max(1, Math.ceil(total / pageSize)) const isLoading = usersQuery.isLoading || quotasQuery.isLoading + 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 function handleSearchChange(e: React.ChangeEvent) { setSearch(e.target.value) setPage(1) + setSelectedIds([]) + } + + function goToPage(nextPage: number) { + setPage(nextPage) + setSelectedIds([]) + } + + function togglePageSelection(checked: boolean) { + setSelectedIds((current) => + checked ? [...new Set([...current, ...pageUserIds])] : current.filter((id) => !pageUserIds.includes(id)), + ) + } + + function toggleUserSelection(userId: string, checked: boolean) { + setSelectedIds((current) => (checked ? [...new Set([...current, userId])] : current.filter((id) => id !== userId))) + } + + function handleBatchDelete() { + if (selectedCount === 0) return + if (!window.confirm(t('admin.users.batchDeleteConfirm', { count: selectedCount }))) return + batchDeleteMutation.mutate(selectedIds) } if (isLoading) { @@ -115,10 +184,51 @@ function UsersPage() { + {selectedCount > 0 && ( +
+ {t('admin.users.selectedCount', { count: selectedCount })} +
+ + + + +
+
+ )} +
+ @@ -129,12 +239,14 @@ function UsersPage() { - {filtered.map((user) => ( + {users.map((user) => ( toggleUserSelection(user.id, checked)} onSetQuota={() => setQuotaDialogUser(user)} onToggleStatus={() => toggleStatusMutation.mutate({ @@ -145,9 +257,9 @@ function UsersPage() { onDelete={() => setDeleteDialogUser({ id: user.id, name: user.name || user.username })} /> ))} - {filtered.length === 0 && ( + {users.length === 0 && ( - @@ -158,13 +270,13 @@ function UsersPage() { {totalPages > 1 && (
- {t('admin.users.pageInfo', { page, total: totalPages })} -
@@ -185,6 +297,23 @@ function UsersPage() { } /> + batchQuotaMutation.mutateAsync(quota)} + showSuccessToast={false} + /> + !open && setDeleteDialogUser(null)} @@ -198,15 +327,19 @@ function UsersPage() { function UserTableRow({ user, + selected, isToggling, showQuota, + onSelect, onSetQuota, onToggleStatus, onDelete, }: { user: UserRow + selected: boolean isToggling: boolean showQuota: boolean + onSelect: (checked: boolean) => void onSetQuota: () => void onToggleStatus: () => void onDelete: () => void @@ -223,6 +356,13 @@ function UserTableRow({ return ( +
+ togglePageSelection(checked === true)} + /> + {t('admin.users.colName')} {t('admin.users.colEmail')} {t('admin.users.colRole')}
+ {t('admin.users.noUsers')}
+ onSelect(checked === true)} + /> + {user.name || user.username} {user.email}