Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes
- Round detail: add skeleton loading for filtering stats, inline results table with expandable rows, pagination, override/reinstate, CSV export, and tooltip on AI summaries button (removes need for separate results page) - Projects: add select-all-across-pages with Gmail-style banner, show country flags with tooltip instead of country codes (table + card views), add listAllIds backend endpoint - Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only - Members: add inline role change via dropdown submenu in user actions, enforce role hierarchy (only super admins can modify admin/super-admin roles) in both backend and UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -185,7 +185,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(),
|
||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
perPage: z.number().int().min(1).max(100).default(20),
|
||||
@@ -340,7 +340,7 @@ export const userRouter = router({
|
||||
id: z.string(),
|
||||
name: z.string().optional().nullable(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||
availabilityJson: z.any().optional(),
|
||||
@@ -362,6 +362,14 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from changing admin roles
|
||||
if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can change admin roles',
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from assigning super admin or admin role
|
||||
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
@@ -708,18 +716,19 @@ export const userRouter = router({
|
||||
where: { id: input.userId },
|
||||
})
|
||||
|
||||
if (user.status !== 'INVITED') {
|
||||
if (user.status !== 'NONE' && user.status !== 'INVITED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'User has already accepted their invitation',
|
||||
})
|
||||
}
|
||||
|
||||
// Generate invite token and store on user
|
||||
// Generate invite token, set status to INVITED, and store on user
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
@@ -766,7 +775,7 @@ export const userRouter = router({
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: input.userIds },
|
||||
status: 'INVITED',
|
||||
status: { in: ['NONE', 'INVITED'] },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -780,11 +789,12 @@ export const userRouter = router({
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Generate invite token for each user
|
||||
// Generate invite token for each user and set status to INVITED
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user