Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
2 changes: 1 addition & 1 deletion apps/web/app/[lang]/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function SignUpForm({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
emailRedirectTo: `${window.location.origin}/${lang}/dashboard`,
Comment thread
gianpaj marked this conversation as resolved.
Outdated
// data: {
// username,
// },
Expand Down
10 changes: 8 additions & 2 deletions apps/web/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ export const forgotPasswordAction = async (formData: FormData) => {
}

const supabase = await createClient();
const origin = (await headers()).get('origin');
const origin =
(await headers()).get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL;
const callbackUrl = formData.get('callbackUrl')?.toString();
const updatePasswordPath = `/${lang}/protected/update-password?email=${encodeURIComponent(email)}`;

if (!origin) {
return encodedRedirect('error', `/${lang}/reset-password`, 'generic_error');
}

const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/${lang}/protected/update-password&email=${encodeURIComponent(email)}`,
redirectTo: new URL(updatePasswordPath, origin).toString(),
});

if (error) {
Expand Down
88 changes: 18 additions & 70 deletions apps/web/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,16 @@ import { createHash } from 'node:crypto';
import { captureException, captureMessage } from '@sentry/nextjs';
import { NextResponse } from 'next/server';

import PostHogClient from '@/lib/posthog';
import { createOrRetrieveCustomer } from '@/lib/stripe/stripe-admin';
import { OAUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants';
import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects';
import {
createOauthCallbackMarkerValue,
OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS,
} from '@/lib/supabase/oauth-callback-marker';
createAuthRedirectResponse,
getLocaleFromRedirectPath,
getSafeAuthRedirectPath,
} from '@/lib/supabase/auth-redirect';
import { AUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants';
import { createClient } from '@/lib/supabase/server';
import { routing } from '@/src/i18n/routing';

const isSafeRedirectPath = (value: string | null) =>
Boolean(value?.startsWith('/') && !value.startsWith('//'));

const getLocaleFromRedirectPath = (redirectPath: string | null) => {
const locale = redirectPath?.split('/')[1];

return routing.locales.includes(locale as (typeof routing.locales)[number])
? locale
: routing.defaultLocale;
};

const getOauthCodeFingerprint = (code: string | null) => {
if (!code) {
return {
Expand Down Expand Up @@ -110,31 +99,12 @@ const getOauthCallbackCookieContext = (request: Request) => {
hasSupabaseCodeVerifierCookie: supabaseCookieNames.some((name) =>
name.includes('code-verifier'),
),
hasOauthCallbackMarkerCookie: cookieNames.includes(
OAUTH_CALLBACK_COOKIE_NAME,
hasAuthCallbackMarkerCookie: cookieNames.includes(
AUTH_CALLBACK_COOKIE_NAME,
),
};
};

const createOauthRedirectResponse = (url: string) => {
const response = NextResponse.redirect(url);
const markerValue = createOauthCallbackMarkerValue();

if (markerValue) {
response.cookies.set({
name: OAUTH_CALLBACK_COOKIE_NAME,
value: markerValue,
httpOnly: true,
maxAge: OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS,
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
}

return response;
};

export async function GET(request: Request) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the SSR package. It exchanges an auth code for the user's session.
Expand All @@ -143,7 +113,8 @@ export async function GET(request: Request) {
const code = requestUrl.searchParams.get('code');
const origin = requestUrl.origin;
const redirectTo = requestUrl.searchParams.get('redirect_to');
const locale = getLocaleFromRedirectPath(redirectTo);
const safeRedirectPath = getSafeAuthRedirectPath(redirectTo, origin);
const locale = getLocaleFromRedirectPath(safeRedirectPath);
const loginPath = `/${locale}/login`;
const oauthCodeContext = getOauthCodeFingerprint(code);
const oauthCookieContext = getOauthCallbackCookieContext(request);
Expand Down Expand Up @@ -220,8 +191,7 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${origin}${loginPath}`);
}

const email = user?.email;
if (!email) {
if (!user?.email) {
captureMessage('OAuth callback completed without a user email.', {
level: 'error',
tags: {
Expand All @@ -238,39 +208,17 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${origin}${loginPath}`);
}

// Add Stripe customer creation
if (user) {
const stripe_id = await createOrRetrieveCustomer(user.id, user.email!);
if (!stripe_id) {
console.error('Failed to create Stripe customer.');
captureMessage('Failed to create Stripe customer.', {
level: 'error',
user: { id: user.id, email: user.email },
});
}

const posthog = PostHogClient();

const login_type =
user.app_metadata.provider === 'email' ? 'email' : 'social';

posthog.capture({
distinctId: user.id,
event: 'sign-up',
properties: {
login_type,
// is_free_trial: true,
},
});
await posthog.shutdown();
}
await recordSignupSideEffects(
user,
user.app_metadata.provider === 'email' ? 'email' : 'social',
);

if (isSafeRedirectPath(redirectTo)) {
return createOauthRedirectResponse(`${origin}${redirectTo}`);
if (safeRedirectPath) {
return createAuthRedirectResponse(`${origin}${safeRedirectPath}`);
}

// URL to redirect to after sign up process completes
return createOauthRedirectResponse(
return createAuthRedirectResponse(
`${origin}/${routing.defaultLocale}/dashboard`,
);
} catch (error) {
Expand Down
127 changes: 127 additions & 0 deletions apps/web/app/auth/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { captureException, captureMessage } from '@sentry/nextjs';
import type { EmailOtpType } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects';
import {
createAuthRedirectResponse,
getLocaleFromAuthHints,
getSafeAuthRedirectPath,
} from '@/lib/supabase/auth-redirect';
import { createClient } from '@/lib/supabase/server';

const EMAIL_OTP_TYPES: ReadonlySet<EmailOtpType> = new Set([
'signup',
'invite',
'magiclink',
'recovery',
'email_change',
'email',
] satisfies EmailOtpType[]);

const SIGNUP_EMAIL_OTP_TYPES: ReadonlySet<EmailOtpType> = new Set([
'signup',
'email',
] satisfies EmailOtpType[]);

const isEmailOtpType = (value: string | null): value is EmailOtpType =>
Boolean(value && EMAIL_OTP_TYPES.has(value as EmailOtpType));

const isSignupEmailOtpType = (type: EmailOtpType) =>
SIGNUP_EMAIL_OTP_TYPES.has(type);

const getDefaultSuccessPath = (type: EmailOtpType, locale: string) =>
type === 'recovery'
? `/${locale}/protected/update-password`
: `/${locale}/dashboard`;

export async function GET(request: Request) {
// Email auth links use token_hash + verifyOtp so confirmation and recovery
// links do not depend on a browser-local PKCE code verifier.
// https://supabase.com/docs/guides/auth/auth-email-templates#redirecting-the-user-to-a-server-side-endpoint
const requestUrl = new URL(request.url);
const origin = requestUrl.origin;
const tokenHash = requestUrl.searchParams.get('token_hash');
const type = requestUrl.searchParams.get('type');
const redirectTo =
requestUrl.searchParams.get('redirect_to') ??
requestUrl.searchParams.get('next');
const safeRedirectPath = getSafeAuthRedirectPath(redirectTo, origin);
const locale = getLocaleFromAuthHints({
acceptLanguage: request.headers.get('accept-language'),
locale: requestUrl.searchParams.get('lang'),
redirectPath: safeRedirectPath,
});
const loginPath = `/${locale}/login`;

if (!(tokenHash && isEmailOtpType(type))) {
captureMessage(
'Email auth confirmation link missing required parameters.',
{
level: 'warning',
tags: {
area: 'auth',
flow: 'email-auth-confirm',
error_type: 'missing-confirmation-params',
},
extra: {
hasTokenHash: Boolean(tokenHash),
type,
redirectTo,
safeRedirectPath,
},
},
);

return NextResponse.redirect(`${origin}${loginPath}`);
}

const supabase = await createClient();
const { data, error } = await supabase.auth.verifyOtp({
token_hash: tokenHash,
type,
});
Comment thread
gianpaj marked this conversation as resolved.

if (error) {
captureException(error, {
tags: {
area: 'auth',
flow: 'email-auth-confirm',
error_type: 'verify-otp-failed',
},
extra: {
type,
redirectTo,
safeRedirectPath,
},
});

return NextResponse.redirect(`${origin}${loginPath}`);
}

if (isSignupEmailOtpType(type)) {
const user = data.user ?? (await supabase.auth.getUser()).data.user;

if (user) {
await recordSignupSideEffects(user, 'email');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard confirmation redirects from side-effect failures

If recordSignupSideEffects() rejects here (for example a Stripe search/create/update failure or PostHog shutdown error), the route has already successfully consumed the one-time token_hash via verifyOtp() but will throw a 500 instead of redirecting the newly confirmed user. The same email link then cannot be retried successfully, so these non-critical provisioning/analytics failures should be caught/logged while still returning the auth redirect.

Useful? React with 👍 / 👎.

} else {
captureMessage('Email signup confirmation completed without a user.', {
level: 'error',
tags: {
area: 'auth',
flow: 'email-auth-confirm',
error_type: 'missing-confirmed-user',
},
extra: {
type,
redirectTo,
safeRedirectPath,
},
});
}
}

return createAuthRedirectResponse(
`${origin}${safeRedirectPath ?? getDefaultSuccessPath(type, locale)}`,
);
}
Comment thread
gianpaj marked this conversation as resolved.
7 changes: 6 additions & 1 deletion apps/web/e2e/E2E_TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> **Status**: ✅ Implemented
> **Last Updated**: 2026-05-10
> **Scope**: `apps/web/e2e/*`
> **Current Result**: 97 tests total — 96 passing, 1 intentionally skipped
> **Current Result**: 99 tests total — 98 passing, 1 intentionally skipped

---

Expand Down Expand Up @@ -41,6 +41,7 @@ The current suite covers the main dashboard surfaces with a mix of:
| History | `/en/dashboard/history` | `history-dashboard.spec.ts` | `pages/history.page.ts` | none currently required | 12 |
| Usage | `/en/dashboard/usage` | `usage-dashboard.spec.ts` | `pages/usage.page.ts` | `mocks/usage.mock.ts` | 14 |
| Profile | `/en/dashboard/profile` | `profile-dashboard.spec.ts` | `pages/profile.page.ts` | one narrow auth-route passthrough in mismatch test | 13 |
| Email auth | `/en/signup`, `/auth/confirm` | `auth-email.spec.ts` | none | mocked browser-side Supabase signup request | 2 |

---

Expand All @@ -56,6 +57,9 @@ Implemented behavior:
- saves auth state to `.auth/user.json`
- all authenticated specs depend on this setup project
- unauthenticated specs override `storageState` with empty cookies/origins
- server-side `/auth/confirm` token verification remains covered by Vitest route
tests because Playwright route mocks cannot intercept Next server outbound
fetches

### Page Object Models

Expand Down Expand Up @@ -419,6 +423,7 @@ apps/web/e2e/
│ ├── history.page.ts
│ ├── profile.page.ts
│ └── usage.page.ts
├── auth-email.spec.ts
├── call-dashboard.spec.ts
├── clone-dashboard.spec.ts
├── credits-dashboard.spec.ts
Expand Down
Loading
Loading