Fix admin settings persistence and batch user operations (#358)

* fix: persist admin settings and batch user operations

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test: cover admin quota edge cases

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* fix: avoid duplicate batch quota toast

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
This commit is contained in:
Jasper Van
2026-05-04 17:20:26 -04:00
committed by GitHub
parent 1b734c0ff9
commit ccc4266b5f
24 changed files with 1008 additions and 79 deletions
+4 -1
View File
@@ -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)
})
})
+3 -7
View File
@@ -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<number> {
const rows = await db
.select({ value: systemOptions.value })
@@ -476,5 +472,5 @@ async function getDefaultOrgQuota(db: Database): Promise<number> {
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
}
+10 -4
View File
@@ -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 () => {
+22
View File
@@ -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)
+1 -1
View File
@@ -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<Env>()
+10
View File
@@ -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)
}
})
})
+7
View File
@@ -52,6 +52,13 @@ const app = new Hono<Env>()
}
}
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)
+182 -2
View File
@@ -14,11 +14,11 @@ async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']
return { Cookie: signInRes.headers.getSetCookie().join('; ') }
}
async function signUpUser(app: ReturnType<typeof import('../app')['createApp']>, email: string) {
async function signUpUser(app: ReturnType<typeof import('../app')['createApp']>, 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<Record<string, unknown>>; 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<Record<string, unknown>>; 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<Record<string, unknown>>; 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<string, unknown>).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<string, unknown>).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)
})
})
+80 -2
View File
@@ -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<Env>()
.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) => {
+106 -3
View File
@@ -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<boolean>
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<string[] | UserOperationFailure> {
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
}