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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user