Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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.
Expand Down Expand Up @@ -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<typeof vi.fn>
const signInOauth2Mock = authClient.signIn.oauth2 as ReturnType<typeof vi.fn>
const signUpEmailMock = authClient.signUp.email as ReturnType<typeof vi.fn>

// Mock fetch globally; per-test override the response.
const fetchMock = vi.fn()
Expand All @@ -103,6 +119,7 @@ beforeEach(() => {
afterEach(() => {
fetchMock.mockReset()
lookupMock.mockReset()
postAuthSuccessMock.mockReset()
})

function okResponse(body: object = { ok: true }) {
Expand Down Expand Up @@ -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(
<PortalAuthForm mode="signup" authConfig={{ password: true, magicLink: false }} openSignup />
)

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(
<PortalAuthFormInline
mode="signup"
authConfig={{ found: false, oauth: { password: true, magicLink: false }, openSignup: true }}
/>
)

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',
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/auth/portal-auth-form-inline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<View>(
Expand Down Expand Up @@ -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
Comment thread
BunsDev marked this conversation as resolved.
} else {
// Stash the current page so the twoFactor client can splice it
// onto its `/auth/two-factor` redirect — the inline form lives
Expand All @@ -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')
Expand Down Expand Up @@ -573,6 +580,35 @@ export function PortalAuthFormInline({
)
}

// ============================================================
// Email verification required after password sign-up
// ============================================================
if (view.stage === 'verify-email') {
return (
<div className="space-y-4 text-center">
<EnvelopeIcon className="mx-auto h-8 w-8 text-primary" />
<div className="space-y-2">
<h2 className="text-lg font-semibold">Check your email</h2>
<p className="text-sm text-muted-foreground">
We sent a verification link to{' '}
<span className="font-medium text-foreground">{email}</span>. Verify your email to
finish creating your account. If you don&apos;t see it, check spam or try signing in
again to resend the verification email.
</p>
Comment thread
BunsDev marked this conversation as resolved.
</div>
{onModeSwitch && (
<button
type="button"
onClick={() => onModeSwitch('login')}
className="text-sm text-primary hover:underline font-medium"
>
Back to sign in
</button>
)}
</div>
)
}

// ============================================================
// Stage 2 — transient SSO redirect spinner
// ============================================================
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/auth/portal-auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
BunsDev marked this conversation as resolved.
} else {
// Stash the post-auth destination so the twoFactor client can
// splice it onto its `/auth/two-factor` redirect if the user
Expand All @@ -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')
Expand Down Expand Up @@ -446,6 +451,35 @@ export function PortalAuthForm({
)
}

// ============================================================
// Email verification required after password sign-up
// ============================================================
if (view.stage === 'verify-email') {
return (
<div className="space-y-4 text-center">
<EnvelopeIcon className="mx-auto h-8 w-8 text-primary" />
<div className="space-y-2">
<h2 className="text-lg font-semibold">Check your email</h2>
<p className="text-sm text-muted-foreground">
We sent a verification link to{' '}
<span className="font-medium text-foreground">{email}</span>. Verify your email to
finish creating your account. If you don&apos;t see it, check spam or try signing in
again to resend the verification email.
</p>
Comment thread
BunsDev marked this conversation as resolved.
</div>
{onModeSwitch && (
<button
type="button"
onClick={() => onModeSwitch('login')}
className="text-sm text-primary hover:underline font-medium"
>
Back to sign in
</button>
)}
</div>
)
}

// ============================================================
// Stage 2 — transient SSO redirect spinner
// ============================================================
Expand Down
24 changes: 22 additions & 2 deletions apps/web/src/lib/server/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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(
Expand All @@ -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: {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/server/functions/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/server/functions/invitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions packages/email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -262,6 +263,39 @@ export async function sendMagicLinkEmail(params: SendMagicLinkParams): Promise<E
})
}

// ============================================================================
// Email Verification
// ============================================================================

interface SendEmailVerificationParams {
to: string
verificationUrl: string
logoUrl?: string
}

export async function sendEmailVerificationEmail(
params: SendEmailVerificationParams
): Promise<EmailResult> {
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
// ============================================================================
Expand Down Expand Up @@ -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'
Loading