diff --git a/docker/.env.production b/docker/.env.production index 0ae5875..f8fddd8 100644 --- a/docker/.env.production +++ b/docker/.env.production @@ -21,12 +21,13 @@ NEXTAUTH_SECRET=CHANGE_ME_use_openssl_rand # ============================================================================= # FILE STORAGE (MinIO - external stack) # ============================================================================= -# Internal endpoint (server-to-server, within Docker host) -MINIO_ENDPOINT=http://localhost:9000 +# Use the public URL — nginx proxies to MinIO internally. +# The port parsing handles standard ports (443 for HTTPS, 80 for HTTP) automatically. +MINIO_ENDPOINT=https://s3.monaco-opc.com -# Public endpoint for browser-accessible pre-signed URLs -# Set this when MinIO is behind a reverse proxy -# MINIO_PUBLIC_ENDPOINT=https://storage.monaco-opc.com +# MINIO_PUBLIC_ENDPOINT is only needed if the internal endpoint differs from the public one. +# When using the public URL as MINIO_ENDPOINT, leave this empty or omit it. +# MINIO_PUBLIC_ENDPOINT= MINIO_ACCESS_KEY=CHANGE_ME MINIO_SECRET_KEY=CHANGE_ME diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2acce59..0a78f0e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,8 +13,9 @@ services: image: ${REGISTRY_URL}/mopc-app:latest container_name: mopc-app restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" + dns: + - 8.8.8.8 + - 8.8.4.4 ports: - "127.0.0.1:7600:7600" env_file: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7313b5f..a42ce87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,6 +236,10 @@ model User { passwordSetAt DateTime? // When password was set mustSetPassword Boolean @default(true) // Force setup on first login + // Invitation token for one-click invite acceptance + inviteToken String? @unique + inviteTokenExpiresAt DateTime? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastLoginAt DateTime? diff --git a/src/app/(auth)/accept-invite/page.tsx b/src/app/(auth)/accept-invite/page.tsx new file mode 100644 index 0000000..f9fce1f --- /dev/null +++ b/src/app/(auth)/accept-invite/page.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import { signIn } from 'next-auth/react' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react' +import { trpc } from '@/lib/trpc/client' + +type InviteState = 'loading' | 'valid' | 'accepting' | 'error' + +export default function AcceptInvitePage() { + const [state, setState] = useState('loading') + const [errorType, setErrorType] = useState(null) + + const searchParams = useSearchParams() + const router = useRouter() + const token = searchParams.get('token') || '' + + const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery( + { token }, + { enabled: !!token, retry: false } + ) + + useEffect(() => { + if (!token) { + setState('error') + setErrorType('MISSING_TOKEN') + return + } + + if (isLoading) { + setState('loading') + return + } + + if (error) { + setState('error') + setErrorType('NETWORK_ERROR') + return + } + + if (data) { + if (data.valid) { + setState('valid') + } else { + setState('error') + setErrorType(data.error || 'UNKNOWN') + } + } + }, [token, data, isLoading, error]) + + const handleAccept = async () => { + setState('accepting') + + try { + const result = await signIn('credentials', { + inviteToken: token, + redirect: false, + }) + + if (result?.error) { + setState('error') + setErrorType('AUTH_FAILED') + } else if (result?.ok) { + // Redirect to set-password (middleware will enforce this since mustSetPassword=true) + window.location.href = '/set-password' + } + } catch { + setState('error') + setErrorType('AUTH_FAILED') + } + } + + const getRoleLabel = (role: string): string => { + switch (role) { + case 'JURY_MEMBER': return 'Jury Member' + case 'PROGRAM_ADMIN': return 'Program Admin' + case 'MENTOR': return 'Mentor' + case 'OBSERVER': return 'Observer' + default: return role + } + } + + const getErrorContent = () => { + switch (errorType) { + case 'MISSING_TOKEN': + return { + icon: , + title: 'Invalid Link', + description: 'This invitation link is incomplete. Please check your email for the correct link.', + } + case 'INVALID_TOKEN': + return { + icon: , + title: 'Invalid Invitation', + description: 'This invitation link is not valid. It may have already been used or the link is incorrect.', + } + case 'EXPIRED_TOKEN': + return { + icon: , + title: 'Invitation Expired', + description: 'This invitation has expired. Please contact your administrator to receive a new invitation.', + } + case 'ALREADY_ACCEPTED': + return { + icon: , + title: 'Already Accepted', + description: 'This invitation has already been accepted. You can sign in with your credentials.', + } + case 'AUTH_FAILED': + return { + icon: , + title: 'Something Went Wrong', + description: 'We couldn\'t complete your account setup. The invitation may have expired. Please try again or contact your administrator.', + } + default: + return { + icon: , + title: 'Something Went Wrong', + description: 'An unexpected error occurred. Please try again or contact your administrator.', + } + } + } + + // Loading state + if (state === 'loading') { + return ( + + + +

Verifying your invitation...

+
+
+ ) + } + + // Error state + if (state === 'error') { + const errorContent = getErrorContent() + return ( + + +
+ {errorContent.icon} +
+ {errorContent.title} + + {errorContent.description} + +
+ + + +
+ ) + } + + // Valid invitation - show welcome + const user = data?.user + return ( + + +
+ +
+ + {user?.name ? `Welcome, ${user.name}!` : 'Welcome!'} + + + You've been invited to join the Monaco Ocean Protection Challenge platform + {user?.role ? ` as a ${getRoleLabel(user.role)}.` : '.'} + +
+ + {user?.email && ( +
+

Signing in as

+

{user.email}

+
+ )} + +

+ You'll be asked to set a password after accepting. +

+
+
+ ) +} diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts index c3fdb34..407e86d 100644 --- a/src/lib/auth.config.ts +++ b/src/lib/auth.config.ts @@ -41,6 +41,7 @@ export const authConfig: NextAuthConfig = { '/login', '/verify-email', '/auth-error', + '/accept-invite', '/api/auth', ] diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5de02ae..cc6cca2 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -19,28 +19,65 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ // Email provider for magic links (used for first login and password reset) EmailProvider({ - server: { - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT || 587), - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, - }, from: process.env.EMAIL_FROM || 'MOPC Platform ', maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes sendVerificationRequest: async ({ identifier: email, url }) => { await sendMagicLinkEmail(email, url) }, }), - // Credentials provider for email/password login + // Credentials provider for email/password login and invite token auth CredentialsProvider({ name: 'credentials', credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, + inviteToken: { label: 'Invite Token', type: 'text' }, }, async authorize(credentials) { + // Handle invite token authentication + if (credentials?.inviteToken) { + const token = credentials.inviteToken as string + const user = await prisma.user.findUnique({ + where: { inviteToken: token }, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + inviteTokenExpiresAt: true, + }, + }) + + if (!user || user.status !== 'INVITED') { + return null + } + + if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) { + return null + } + + // Clear token, activate user, mark as needing password + await prisma.user.update({ + where: { id: user.id }, + data: { + inviteToken: null, + inviteTokenExpiresAt: null, + status: 'ACTIVE', + mustSetPassword: true, + lastLoginAt: new Date(), + }, + }) + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + mustSetPassword: true, + } + } + if (!credentials?.email || !credentials?.password) { return null } diff --git a/src/lib/email.ts b/src/lib/email.ts index 7932487..eecb3b9 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -294,7 +294,7 @@ function getGenericInvitationTemplate( ${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.`)} ${paragraph('Click the button below to set up your account and get started.')} ${ctaButton(url, 'Accept Invitation')} - ${infoBox('This link will expire in 24 hours.', 'info')} + ${infoBox('This link will expire in 7 days.', 'info')} ` return { @@ -309,7 +309,7 @@ Click the link below to set up your account and get started: ${url} -This link will expire in 24 hours. +This link will expire in 7 days. --- Monaco Ocean Protection Challenge diff --git a/src/lib/minio.ts b/src/lib/minio.ts index fc30bac..ec57c0c 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -17,7 +17,7 @@ function createMinioClient(): Minio.Client { return new Minio.Client({ endPoint: url.hostname, - port: parseInt(url.port) || 9000, + port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80), useSSL: url.protocol === 'https:', accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index cb668e3..01a1b5c 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -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({