Fix S3/SMTP connectivity and add one-click invite flow

- Fix MinIO port parsing bug: use protocol-appropriate defaults (443/80)
  instead of hardcoded 9000 fallback, enabling public URL endpoint
- Remove unused SMTP server config from NextAuth EmailProvider to prevent
  connection errors (sendVerificationRequest is fully overridden)
- Replace extra_hosts with DNS config (8.8.8.8) so container resolves
  mail.monaco-opc.com to public IP instead of host loopback
- Add invite token auth: single-click accept-invite flow replacing broken
  two-email invitation process
- Auto-send invitation emails on bulk user creation
- Update email template expiry text from 24 hours to 7 days

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:13:16 +01:00
parent 5aedade41d
commit 81db15333f
9 changed files with 387 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
@@ -5,6 +6,12 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export const userRouter = router({
/**
* Get current user profile
@@ -29,6 +36,35 @@ export const userRouter = router({
})
}),
/**
* Validate an invitation token (public, no auth required)
*/
validateInviteToken: publicProcedure
.input(z.object({ token: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: { inviteToken: input.token },
select: { id: true, name: true, email: true, role: true, status: true, inviteTokenExpiresAt: true },
})
if (!user) {
return { valid: false, error: 'INVALID_TOKEN' as const }
}
if (user.status !== 'INVITED') {
return { valid: false, error: 'ALREADY_ACCEPTED' as const }
}
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
return { valid: false, error: 'EXPIRED_TOKEN' as const }
}
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
}
}),
/**
* Update current user profile
*/
@@ -415,7 +451,56 @@ export const userRouter = router({
},
})
return { created: created.count, skipped }
// Auto-send invitation emails to newly created users
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const createdUsers = await ctx.prisma.user.findMany({
where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
select: { id: true, email: true, name: true, role: true },
})
let emailsSent = 0
const emailErrors: string[] = []
for (const user of createdUsers) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
emailsSent++
} catch (e) {
emailErrors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
}
}
return { created: created.count, skipped, emailsSent, emailErrors }
}),
/**
@@ -487,9 +572,18 @@ export const userRouter = router({
})
}
// Generate magic link URL
// Generate invite token and store on user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}`
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
@@ -544,7 +638,17 @@ export const userRouter = router({
for (const user of users) {
try {
const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}`
// Generate invite token for each user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
await ctx.prisma.notificationLog.create({