Award shortlist UX improvements + configurable invite link expiry
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:
2026-02-17 22:05:58 +01:00
parent 8a7da0fd93
commit d02b0b91b9
6 changed files with 156 additions and 24 deletions

View File

@@ -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: {