diff --git a/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx b/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx index 1423049c0..6ec2b331e 100644 --- a/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx +++ b/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx @@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react' +const { postAuthSuccessMock } = vi.hoisted(() => ({ + postAuthSuccessMock: vi.fn(), +})) + vi.mock('@/lib/client/auth-client', () => ({ authClient: { signIn: { @@ -14,6 +18,16 @@ vi.mock('@/lib/client/auth-client', () => ({ }, })) +vi.mock('@/lib/client/hooks/use-auth-broadcast', () => ({ + postAuthSuccess: postAuthSuccessMock, + usePopupTracker: () => ({ + trackPopup: vi.fn(), + clearPopup: vi.fn(), + hasPopup: vi.fn(() => false), + focusPopup: vi.fn(() => false), + }), +})) + // OAuth buttons reach into broadcast/popup hooks that aren't relevant // to the email-only auth flows we're testing — stub the whole module // so the form renders without those side effects. @@ -90,10 +104,12 @@ vi.mock('@/components/ui/input-otp', () => ({ })) import { PortalAuthForm } from '../portal-auth-form' +import { PortalAuthFormInline } from '../portal-auth-form-inline' import { authClient } from '@/lib/client/auth-client' const signInEmailOtpMock = authClient.signIn.emailOtp as ReturnType const signInOauth2Mock = authClient.signIn.oauth2 as ReturnType +const signUpEmailMock = authClient.signUp.email as ReturnType // Mock fetch globally; per-test override the response. const fetchMock = vi.fn() @@ -103,6 +119,7 @@ beforeEach(() => { afterEach(() => { fetchMock.mockReset() lookupMock.mockReset() + postAuthSuccessMock.mockReset() }) function okResponse(body: object = { ok: true }) { @@ -262,6 +279,71 @@ describe('PortalAuthForm — Stage 1 → Stage 2 dispatch', () => { expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument() }) + it('keeps password signup on the email-verification step without broadcasting auth success', async () => { + lookupMock.mockResolvedValue({ + kind: 'methods', + authConfig: { password: true, magicLink: false }, + ssoEnabled: false, + }) + signUpEmailMock.mockResolvedValue({ data: {}, error: null }) + render( + + ) + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'new-user@example.com' }, + }) + fireEvent.click(screen.getByRole('button', { name: /continue/i })) + await screen.findByLabelText(/^password$/i) + + fireEvent.change(screen.getByLabelText(/^password$/i), { + target: { value: 'hunter222' }, + }) + fireEvent.click(screen.getByRole('button', { name: /create account/i })) + + await screen.findByRole('heading', { name: /check your email/i }) + expect(screen.getByText(/new-user@example.com/i)).toBeInTheDocument() + expect(screen.getByText(/check spam/i)).toBeInTheDocument() + expect(signUpEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new-user@example.com', password: 'hunter222' }) + ) + expect(postAuthSuccessMock).not.toHaveBeenCalled() + }) + + it('keeps inline password signup on the email-verification step without broadcasting auth success', async () => { + lookupMock.mockResolvedValue({ + kind: 'methods', + authConfig: { password: true, magicLink: false }, + ssoEnabled: false, + }) + signUpEmailMock.mockResolvedValue({ data: {}, error: null }) + render( + + ) + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'inline-user@example.com' }, + }) + fireEvent.click(screen.getByRole('button', { name: /continue/i })) + await screen.findByLabelText(/^password$/i) + + fireEvent.change(screen.getByLabelText(/^password$/i), { + target: { value: 'hunter333' }, + }) + fireEvent.click(screen.getByRole('button', { name: /create account/i })) + + await screen.findByRole('heading', { name: /check your email/i }) + expect(screen.getByText(/inline-user@example.com/i)).toBeInTheDocument() + expect(screen.getByText(/check spam/i)).toBeInTheDocument() + expect(signUpEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ email: 'inline-user@example.com', password: 'hunter333' }) + ) + expect(postAuthSuccessMock).not.toHaveBeenCalled() + }) + it('back link from Stage 2 returns to Stage 1 and clears the password', async () => { lookupMock.mockResolvedValue({ kind: 'methods', diff --git a/apps/web/src/components/auth/portal-auth-form-inline.tsx b/apps/web/src/components/auth/portal-auth-form-inline.tsx index 1b6ca7418..f7320ebe8 100644 --- a/apps/web/src/components/auth/portal-auth-form-inline.tsx +++ b/apps/web/src/components/auth/portal-auth-form-inline.tsx @@ -126,6 +126,7 @@ export function PortalAuthFormInline({ const passwordEnabled = authConfig?.oauth?.password ?? true const magicLinkEnabled = authConfig?.oauth?.magicLink ?? false const openSignup = authConfig?.openSignup + const callbackUrl = '/' const methodsDefaultStep: AuthFormStep = !passwordEnabled && magicLinkEnabled ? 'email' : 'credentials' @@ -139,6 +140,7 @@ export function PortalAuthFormInline({ | { stage: 'sso-unavailable' } | { stage: 'closed-signup' } | { stage: 'sso-redirecting' } + | { stage: 'verify-email' } // Invitation flow: the email is server-known, so Stage 1 is moot. const [view, setView] = useState( @@ -282,10 +284,14 @@ export function PortalAuthFormInline({ name: name.trim() || email.split('@')[0], email, password, + callbackURL: callbackUrl, }) if (result.error) { throw new Error(result.error.message || 'Failed to create account') } + setView({ stage: 'verify-email' }) + setLoadingAction(null) + return } else { // Stash the current page so the twoFactor client can splice it // onto its `/auth/two-factor` redirect — the inline form lives @@ -296,6 +302,7 @@ export function PortalAuthFormInline({ const result = await authClient.signIn.email({ email, password, + callbackURL: callbackUrl, }) if (result.error) { throw new Error(result.error.message || 'Invalid email or password') @@ -573,6 +580,35 @@ export function PortalAuthFormInline({ ) } + // ============================================================ + // Email verification required after password sign-up + // ============================================================ + if (view.stage === 'verify-email') { + return ( +
+ +
+

Check your email

+

+ We sent a verification link to{' '} + {email}. Verify your email to + finish creating your account. If you don't see it, check spam or try signing in + again to resend the verification email. +

+
+ {onModeSwitch && ( + + )} +
+ ) + } + // ============================================================ // Stage 2 — transient SSO redirect spinner // ============================================================ diff --git a/apps/web/src/components/auth/portal-auth-form.tsx b/apps/web/src/components/auth/portal-auth-form.tsx index 24d0702d1..87b6240e9 100644 --- a/apps/web/src/components/auth/portal-auth-form.tsx +++ b/apps/web/src/components/auth/portal-auth-form.tsx @@ -105,6 +105,7 @@ export function PortalAuthForm({ | { stage: 'sso-unavailable' } | { stage: 'closed-signup' } | { stage: 'sso-redirecting' } + | { stage: 'verify-email' } // Start state: invitation OR initialEmail bypasses Stage 1. const skipStage1 = !!(initialEmail || invitationId) @@ -237,10 +238,13 @@ export function PortalAuthForm({ name: name.trim() || email.split('@')[0], email, password, + callbackURL: callbackUrl, }) if (result.error) { throw new Error(result.error.message || 'Failed to create account') } + setView({ stage: 'verify-email' }) + return } else { // Stash the post-auth destination so the twoFactor client can // splice it onto its `/auth/two-factor` redirect if the user @@ -249,6 +253,7 @@ export function PortalAuthForm({ const result = await authClient.signIn.email({ email, password, + callbackURL: callbackUrl, }) if (result.error) { throw new Error(result.error.message || 'Invalid email or password') @@ -446,6 +451,35 @@ export function PortalAuthForm({ ) } + // ============================================================ + // Email verification required after password sign-up + // ============================================================ + if (view.stage === 'verify-email') { + return ( +
+ +
+

Check your email

+

+ We sent a verification link to{' '} + {email}. Verify your email to + finish creating your account. If you don't see it, check spam or try signing in + again to resend the verification email. +

+
+ {onModeSwitch && ( + + )} +
+ ) + } + // ============================================================ // Stage 2 — transient SSO redirect spinner // ============================================================ diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index 83f30cddd..6f6145ed8 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -86,7 +86,9 @@ async function createAuth() { twoFactor: twoFactorTable, eq, } = await import('@/lib/server/db') - const { sendPasswordResetEmail, isEmailConfigured } = await import('@opencoven-feedback/email') + const { sendEmailVerificationEmail, sendPasswordResetEmail, isEmailConfigured } = await import( + '@opencoven-feedback/email' + ) const { getPlatformCredentials } = await import('@/lib/server/domains/platform-credentials/platform-credential.service') const { getAllAuthProviders } = await import('./auth-providers') @@ -329,7 +331,8 @@ async function createAuth() { enabled: true, minPasswordLength: 8, maxPasswordLength: 128, - autoSignIn: true, + autoSignIn: false, + requireEmailVerification: true, async sendResetPassword({ user, url }) { if (!isEmailConfigured()) { console.warn( @@ -345,6 +348,23 @@ async function createAuth() { resetPasswordTokenExpiresIn: 60 * 60 * 24, // 24 hours }, + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: true, + async sendVerificationEmail({ user, url }) { + if (!isEmailConfigured()) { + console.warn( + `[auth] Email verification requested for ${user.email} but email is not configured. Link will be logged to the console.` + ) + } + const { getEmailSafeUrl } = await import('@/lib/server/storage/s3') + const settings = await db.query.settings.findFirst({ columns: { logoKey: true } }) + const logoUrl = getEmailSafeUrl(settings?.logoKey) ?? undefined + await sendEmailVerificationEmail({ to: user.email, verificationUrl: url, logoUrl }) + }, + }, + // Account linking - allow users to link multiple OAuth providers to their account // This is needed when a user signs up with email OTP, then later signs in with GitHub/Google account: { diff --git a/apps/web/src/lib/server/functions/admin.ts b/apps/web/src/lib/server/functions/admin.ts index 84261f2d5..b531049db 100644 --- a/apps/web/src/lib/server/functions/admin.ts +++ b/apps/web/src/lib/server/functions/admin.ts @@ -944,6 +944,12 @@ export const sendInvitationFn = createServerFn({ method: 'POST' }) } if (existingUser) { + if (!existingUser.emailVerified) { + throw new Error( + 'This email belongs to an unverified portal account. Ask the user to verify their email before inviting them.' + ) + } + // Check if they already have a team member role (admin or member) const existingPrincipal = await db.query.principal.findFirst({ where: eq(principal.userId, existingUser.id), @@ -952,7 +958,7 @@ export const sendInvitationFn = createServerFn({ method: 'POST' }) if (existingPrincipal && existingPrincipal.role !== 'user') { throw new Error('A team member with this email already exists') } - // Portal users (role='user' or no member record) can be invited to become team members + // Verified portal users (role='user' or no member record) can be invited to become team members } const invitationId = generateId('invite') diff --git a/apps/web/src/lib/server/functions/invitations.ts b/apps/web/src/lib/server/functions/invitations.ts index d6d88ff99..4c7810fe8 100644 --- a/apps/web/src/lib/server/functions/invitations.ts +++ b/apps/web/src/lib/server/functions/invitations.ts @@ -142,6 +142,10 @@ export const acceptInvitationFn = createServerFn({ method: 'POST' }) ) } + if (!session.user.emailVerified) { + throw new Error('Please verify your email address before accepting this invitation.') + } + // Atomically claim the invitation with a conditional update to prevent // double-accept race conditions (e.g., double-click, network retry). const [claimed] = await db diff --git a/packages/email/src/index.ts b/packages/email/src/index.ts index 902b9f8c4..02e361477 100644 --- a/packages/email/src/index.ts +++ b/packages/email/src/index.ts @@ -20,6 +20,7 @@ import { PostMentionEmail } from './templates/post-mention' import { ChangelogPublishedEmail } from './templates/changelog-published' import { FeedbackLinkedEmail } from './templates/feedback-linked' import { PasswordResetEmail } from './templates/password-reset' +import { EmailVerificationEmail } from './templates/email-verification' import { RecoveryCodeUsedEmail } from './templates/recovery-code-used' import { NewSignInEmail } from './templates/new-sign-in' @@ -262,6 +263,39 @@ export async function sendMagicLinkEmail(params: SendMagicLinkParams): Promise { + const { to, verificationUrl, logoUrl } = params + + if (getProvider() === 'console') { + console.log('\n┌────────────────────────────────────────────────────────────') + console.log('│ [DEV] Email Verification') + console.log('├────────────────────────────────────────────────────────────') + console.log(`│ To: ${to}`) + console.log(`│ Verification link: ${verificationUrl}`) + console.log('└────────────────────────────────────────────────────────────\n') + return { sent: false } + } + + console.log(`[Email] Sending email verification to ${to}`) + return sendEmail({ + to, + subject: 'Verify your Quackback email', + react: EmailVerificationEmail({ verificationUrl, logoUrl }), + }) +} + // ============================================================================ // Password Reset Email // ============================================================================ @@ -671,6 +705,7 @@ export { NewCommentEmail } from './templates/new-comment' export { PostMentionEmail } from './templates/post-mention' export { ChangelogPublishedEmail } from './templates/changelog-published' export { FeedbackLinkedEmail } from './templates/feedback-linked' +export { EmailVerificationEmail } from './templates/email-verification' export { PasswordResetEmail } from './templates/password-reset' export { RecoveryCodeUsedEmail } from './templates/recovery-code-used' export { NewSignInEmail } from './templates/new-sign-in' diff --git a/packages/email/src/templates/email-verification.tsx b/packages/email/src/templates/email-verification.tsx new file mode 100644 index 000000000..61365b68f --- /dev/null +++ b/packages/email/src/templates/email-verification.tsx @@ -0,0 +1,39 @@ +import { Button, Heading, Link, Section, Text } from '@react-email/components' +import { EmailLayout, TransactionalFooter } from './email-layout' +import { typography, button, utils } from './shared-styles' + +interface EmailVerificationEmailProps { + verificationUrl: string + logoUrl?: string +} + +export function EmailVerificationEmail({ + verificationUrl, + logoUrl, +}: EmailVerificationEmailProps) { + return ( + + Verify your email + + Click the button below to verify your email address and finish setting up your account. + + +
+ +
+ + + Or copy and paste this link into your browser:{' '} + + {verificationUrl} + + + + + If you didn't create an account, you can safely ignore this email. + +
+ ) +}