Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
Award shortlist: - Expandable reasoning text (click to toggle, hover hint) - Bulk select/deselect all checkbox in header - Top N projects highlighted with amber background - New bulkToggleShortlisted backend mutation Invite link expiry: - New "Invitation Link Expiry (hours)" field in Security Settings - Reads from systemSettings `invite_link_expiry_hours` (default 72h / 3 days) - Email template dynamically shows "X hours" or "X days" based on setting - All 3 invite paths (bulk create, single invite, bulk resend) use setting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,24 @@ import { hashPassword, validatePassword } from '@/lib/password'
|
||||
import { attachAvatarUrls } from '@/server/utils/avatar-url'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
|
||||
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
|
||||
|
||||
async function getInviteExpiryHours(prisma: import('@prisma/client').PrismaClient): Promise<number> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'invite_link_expiry_hours' },
|
||||
select: { value: true },
|
||||
})
|
||||
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
|
||||
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
|
||||
} catch {
|
||||
return DEFAULT_INVITE_EXPIRY_HOURS
|
||||
}
|
||||
}
|
||||
|
||||
async function getInviteExpiryMs(prisma: import('@prisma/client').PrismaClient): Promise<number> {
|
||||
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
function generateInviteToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
@@ -795,6 +812,8 @@ export const userRouter = router({
|
||||
|
||||
if (input.sendInvitation) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
const expiryHours = await getInviteExpiryHours(ctx.prisma)
|
||||
const expiryMs = expiryHours * 60 * 60 * 1000
|
||||
|
||||
for (const user of createdUsers) {
|
||||
try {
|
||||
@@ -803,12 +822,12 @@ export const userRouter = router({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
@@ -937,12 +956,13 @@ export const userRouter = router({
|
||||
|
||||
// Generate invite token, set status to INVITED, and store on user
|
||||
const token = generateInviteToken()
|
||||
const expiryHours = await getInviteExpiryHours(ctx.prisma)
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -950,7 +970,7 @@ export const userRouter = router({
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
|
||||
// Send invitation email
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||
|
||||
// Log notification
|
||||
await ctx.prisma.notificationLog.create({
|
||||
@@ -996,6 +1016,8 @@ export const userRouter = router({
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
const expiryHours = await getInviteExpiryHours(ctx.prisma)
|
||||
const expiryMs = expiryHours * 60 * 60 * 1000
|
||||
let sent = 0
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -1008,12 +1030,12 @@ export const userRouter = router({
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
||||
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user