From 9f4f3448956f5763a37a9075373ac1d3f83dde30 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 13:27:39 -0700 Subject: [PATCH 1/7] feat: prepare user profile auth migration --- .env.example | 7 + .gitattributes | 2 + .../forgot-password/forgot-password-form.tsx | 113 ++++++++ src/app/(auth)/forgot-password/page.tsx | 115 +------- src/app/(auth)/sign-in/login-form.tsx | 172 ++++++++++++ src/app/(auth)/sign-in/page.tsx | 173 +----------- src/app/(auth)/sign-up/page.tsx | 231 +--------------- src/app/(auth)/sign-up/signup-form.tsx | 249 ++++++++++++++++++ src/app/dashboard/[teamSlug]/layout.tsx | 20 +- src/app/dashboard/[teamSlug]/team-gate.tsx | 17 +- src/configs/flags.ts | 14 + src/core/application/user/queries.ts | 10 + .../modules/teams/teams-repository.server.ts | 43 ++- src/core/server/actions/auth-actions.ts | 56 +++- src/core/server/actions/user-actions.ts | 140 ---------- src/core/server/api/routers/index.ts | 2 + src/core/server/api/routers/user.ts | 136 ++++++++++ src/core/server/auth/ory/provider.ts | 35 ++- src/core/server/auth/provider.ts | 18 +- src/core/server/auth/supabase/flows.ts | 21 -- src/core/server/auth/supabase/provider.ts | 127 ++++++++- src/core/server/auth/supabase/user.ts | 16 +- src/core/server/auth/types.ts | 35 ++- src/core/server/http/proxy.ts | 81 ++++++ src/features/auth/oauth-provider-buttons.tsx | 26 +- .../dashboard/account/email-settings.tsx | 199 ++++++++------ .../dashboard/account/name-settings.tsx | 34 ++- .../dashboard/account/password-settings.tsx | 74 +++--- .../dashboard/account/reauth-dialog.tsx | 15 +- .../dashboard/account/user-access-token.tsx | 18 +- src/lib/env.ts | 2 + src/proxy.ts | 103 ++------ tests/integration/auth.test.ts | 19 ++ tests/unit/auth-supabase-provider.test.ts | 22 +- tests/unit/proxy-handlers.test.ts | 82 ++++++ tests/unit/teams-repository.test.ts | 13 - tests/unit/user-router.test.ts | 61 +++++ 37 files changed, 1532 insertions(+), 969 deletions(-) create mode 100644 .gitattributes create mode 100644 src/app/(auth)/forgot-password/forgot-password-form.tsx create mode 100644 src/app/(auth)/sign-in/login-form.tsx create mode 100644 src/app/(auth)/sign-up/signup-form.tsx create mode 100644 src/core/application/user/queries.ts delete mode 100644 src/core/server/actions/user-actions.ts create mode 100644 src/core/server/api/routers/user.ts create mode 100644 tests/unit/proxy-handlers.test.ts create mode 100644 tests/unit/user-router.test.ts diff --git a/.env.example b/.env.example index 239f6cd20..578f53cd8 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Set to 1 to enable verbose logging # NEXT_PUBLIC_VERBOSE=0 + +### Set to 1 to pause new sign-ups during auth migration. +### Existing users can still sign in and invite team members. +# NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS=0 + +### Set to 1 to temporarily disable GitHub OAuth sign-ins during auth migration cutover. +# NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED=0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..71c1aa0de --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +spec/openapi.dashboard-api.yaml linguist-generated=true +src/core/shared/contracts/dashboard-api.types.ts linguist-generated=true diff --git a/src/app/(auth)/forgot-password/forgot-password-form.tsx b/src/app/(auth)/forgot-password/forgot-password-form.tsx new file mode 100644 index 000000000..a6ced72c5 --- /dev/null +++ b/src/app/(auth)/forgot-password/forgot-password-form.tsx @@ -0,0 +1,113 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { forgotPasswordAction } from '@/core/server/actions/auth-actions' +import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { Button } from '@/ui/primitives/button' +import { Input } from '@/ui/primitives/input' +import { Label } from '@/ui/primitives/label' + +export default function ForgotPassword() { + const searchParams = useSearchParams() + const [message, setMessage] = useState() + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction( + forgotPasswordAction, + zodResolver(forgotPasswordSchema), + { + actionProps: { + onSuccess: () => { + form.reset() + setMessage({ success: USER_MESSAGES.passwordReset.message }) + }, + onError: ({ error }) => { + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + } + ) + + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + } + }, [searchParams, form]) + + useEffect(() => { + if (!message) return + + const messageText = + 'success' in message + ? message.success + : 'error' in message + ? message.error + : undefined + + if (!messageText) return + + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage(messageText) || 5000 + ) + return () => clearTimeout(timer) + }, [message]) + + const handleBackToSignIn = () => { + const email = form.getValues('email') + const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' + window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` + } + + return ( +
+

Reset Password

+

+ Remember your password?{' '} + + . +

+ +
+ + + +
+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 8c8e1d4ac..4b75ef913 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,114 +1,5 @@ -'use client' +import ForgotPassword from './forgot-password-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useSearchParams } from 'next/navigation' -import { useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { forgotPasswordAction } from '@/core/server/actions/auth-actions' -import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { Button } from '@/ui/primitives/button' -import { Input } from '@/ui/primitives/input' -import { Label } from '@/ui/primitives/label' - -export default function ForgotPassword() { - const searchParams = useSearchParams() - const [message, setMessage] = useState() - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction( - forgotPasswordAction, - zodResolver(forgotPasswordSchema), - { - actionProps: { - onSuccess: () => { - form.reset() - setMessage({ success: USER_MESSAGES.passwordReset.message }) - }, - onError: ({ error }) => { - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - } - ) - - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - } - }, [searchParams, form]) - - useEffect(() => { - if ( - message && - (('success' in message && message.success) || - ('error' in message && message.error)) - ) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage( - 'success' in message - ? message.success! - : 'error' in message - ? message.error! - : '' - ) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) - - const handleBackToSignIn = () => { - const email = form.getValues('email') - const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' - window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` - } - - return ( -
-

Reset Password

-

- Remember your password?{' '} - - . -

- -
- - - -
- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-in/login-form.tsx b/src/app/(auth)/sign-in/login-form.tsx new file mode 100644 index 000000000..6da5d319f --- /dev/null +++ b/src/app/(auth)/sign-in/login-form.tsx @@ -0,0 +1,172 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { USER_MESSAGES } from '@/configs/user-messages' +import { signInAction } from '@/core/server/actions/auth-actions' +import { signInSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function Login() { + 'use no memo' + + const searchParams = useSearchParams() + + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + return undefined + }) + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signInAction, zodResolver(signInSchema), { + actionProps: { + onError: ({ error }) => { + if ( + error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message + ) { + setMessage({ success: error.serverError }) + return + } + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const returnTo = searchParams.get('returnTo') || undefined + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill from forgot password flow + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + // Focus password field if email is prefilled + form.setFocus('password') + } else { + // Focus email field if no prefill + form.setFocus('email') + } + }, [searchParams, form]) + + const handleForgotPassword = () => { + const email = form.getValues('email') + const params = new URLSearchParams() + if (email) params.set('email', email) + if (returnTo) params.set('returnTo', returnTo) + window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` + } + + return ( +
+

Sign in

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + +
+ Password + +
+ + ( + + + + + + + )} + /> + + + + + + + +

+ Don't have an account?{' '} + + Sign up + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 6da5d319f..30f2a3f11 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,172 +1,5 @@ -'use client' +import Login from './login-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { USER_MESSAGES } from '@/configs/user-messages' -import { signInAction } from '@/core/server/actions/auth-actions' -import { signInSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function Login() { - 'use no memo' - - const searchParams = useSearchParams() - - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - return undefined - }) - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signInAction, zodResolver(signInSchema), { - actionProps: { - onError: ({ error }) => { - if ( - error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message - ) { - setMessage({ success: error.serverError }) - return - } - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const returnTo = searchParams.get('returnTo') || undefined - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill from forgot password flow - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - // Focus password field if email is prefilled - form.setFocus('password') - } else { - // Focus email field if no prefill - form.setFocus('email') - } - }, [searchParams, form]) - - const handleForgotPassword = () => { - const email = form.getValues('email') - const params = new URLSearchParams() - if (email) params.set('email', email) - if (returnTo) params.set('returnTo', returnTo) - window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` - } - - return ( -
-

Sign in

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - -
- Password - -
- - ( - - - - - - - )} - /> - - - - - - - -

- Don't have an account?{' '} - - Sign up - - . -

- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index ad1d2c165..12d998e4e 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,230 +1,5 @@ -'use client' +import SignUp from './signup-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useRef, useState } from 'react' -import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { signUpAction } from '@/core/server/actions/auth-actions' -import { signUpSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { TurnstileWidget } from '@/features/auth/turnstile-widget' -import { useTurnstile } from '@/features/auth/use-turnstile' -import { isGoogleEmail } from '@/lib/utils/email' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function SignUp() { - 'use no memo' - - const searchParams = useSearchParams() - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - - return undefined - }) - - const turnstileResetRef = useRef<() => void>(() => {}) - - const returnTo = searchParams.get('returnTo') || undefined - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { - actionProps: { - onSuccess: () => { - turnstileResetRef.current() - setMessage({ success: USER_MESSAGES.signUpVerification.message }) - }, - onError: ({ error }) => { - turnstileResetRef.current() - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const turnstile = useTurnstile(form) - turnstileResetRef.current = turnstile.reset - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - form.setFocus('password') - } else { - form.setFocus('email') - } - }, [searchParams, form]) - - const emailValue = form.watch('email') - const isGoogleSignUp = emailValue ? isGoogleEmail(emailValue) : false - - useEffect(() => { - if (message && 'success' in message && message.success) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage(message.success) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) - - return ( -
-

Sign up

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - - Password - ( - - - - - - - )} - /> - - ( - - - - - - - )} - /> - - - - - - - {isGoogleSignUp && ( - - )} - - - - - -

- Already have an account?{' '} - - Sign in - - . -

-

- By signing up, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . -

- - {message && } -
- ) +export default function Page() { + return } diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx new file mode 100644 index 000000000..588b34f59 --- /dev/null +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -0,0 +1,249 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, useState } from 'react' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_CLIENT, +} from '@/configs/flags' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { signUpAction } from '@/core/server/actions/auth-actions' +import { signUpSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { TurnstileWidget } from '@/features/auth/turnstile-widget' +import { useTurnstile } from '@/features/auth/use-turnstile' +import { isGoogleEmail } from '@/lib/utils/email' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function SignUp() { + 'use no memo' + + const searchParams = useSearchParams() + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + + return undefined + }) + + const turnstileResetRef = useRef<() => void>(() => {}) + + const returnTo = searchParams.get('returnTo') || undefined + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { + actionProps: { + onSuccess: () => { + turnstileResetRef.current() + setMessage({ success: USER_MESSAGES.signUpVerification.message }) + }, + onError: ({ error }) => { + turnstileResetRef.current() + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const turnstile = useTurnstile(form) + turnstileResetRef.current = turnstile.reset + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + form.setFocus('password') + } else { + form.setFocus('email') + } + }, [searchParams, form]) + + useEffect(() => { + if (message && 'success' in message && message.success) { + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage(message.success) || 5000 + ) + return () => clearTimeout(timer) + } + }, [message]) + + const emailValue = form.watch('email') + const isGoogleSignUp = emailValue ? isGoogleEmail(emailValue) : false + + if (AUTH_MIGRATION_IN_PROGRESS) { + return ( +
+

Sign up

+

+ New sign-ups are temporarily paused while we migrate our + authentication system. Existing users can still{' '} + + sign in + + . +

+
+ ) + } + + return ( +
+

Sign up

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + + Password + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + + + + + {isGoogleSignUp && ( + + )} + + + + + +

+ Already have an account?{' '} + + Sign in + + . +

+

+ By signing up, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 389737aaa..99251f84d 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -6,6 +6,7 @@ import { COOKIE_KEYS } from '@/configs/cookies' import { METADATA } from '@/configs/metadata' import { AUTH_URLS } from '@/configs/urls' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { auth } from '@/core/server/auth' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' @@ -43,13 +44,24 @@ export default async function DashboardLayout({ throw redirect(AUTH_URLS.SIGN_IN) } - await prefetchAsync( - trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) - ) + await Promise.all([ + prefetchAsync( + trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + ), + prefetchAsync( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ), + ]) return ( - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index 4278c92d0..db1ab0dbf 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' -import type { AuthUser } from '@/core/server/auth' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { DashboardContextProvider } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' @@ -10,28 +10,33 @@ import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string - user: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, - user, children, }: DashboardTeamGateProps) { const trpc = useTRPC() - const { data: teams, isPending } = useQuery( + const { data: teams, isPending: teamsPending } = useQuery( trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) ) - if (isPending) { + const { data: user, isPending: userPending } = useQuery( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ) + + if (teamsPending || userPending) { return } const team = teams?.find((candidate) => candidate.slug === teamSlug) - if (!team || !teams) { + if (!team || !teams || !user) { return } diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 66a0b604f..879be4141 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -29,3 +29,17 @@ export const CAPTCHA_REQUIRED_SERVER = export function isOryAuthEnabled() { return process.env.AUTH_PROVIDER === 'ory' } + +// Freezes new identity creation while we migrate identity stores. +// When on: blocks new sign-ups (email/password + freshly-registered OIDC +// identities). Existing users keep signing in normally. +export const AUTH_MIGRATION_IN_PROGRESS = + process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' + +// Temporarily disables GitHub OAuth entry points during auth provider cutovers, +// when GitHub's callback URL may need to move between providers. +export function isGithubSignInDisabled() { + return process.env.NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED === '1' +} + +export const AUTH_GITHUB_SIGN_IN_DISABLED = isGithubSignInDisabled() diff --git a/src/core/application/user/queries.ts b/src/core/application/user/queries.ts new file mode 100644 index 000000000..eaa1419d2 --- /dev/null +++ b/src/core/application/user/queries.ts @@ -0,0 +1,10 @@ +// Mirrors DASHBOARD_TEAMS_LIST_QUERY_OPTIONS: the profile is prefetched once in +// the dashboard layout and treated as fresh on the client, so it isn't refetched +// on every mount/focus. Cache updates after account mutations come from explicit +// setQueryData calls in the account-settings forms. +export const DASHBOARD_USER_PROFILE_QUERY_OPTIONS = { + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} as const diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 1e5524791..5840d76a0 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -2,8 +2,6 @@ import 'server-only' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' -import type { AuthAdmin } from '@/core/server/auth' -import { authAdmin } from '@/core/server/auth' import { api } from '@/core/shared/clients/api' import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' @@ -13,7 +11,6 @@ import type { TeamMember } from './models' type TeamsRepositoryDeps = { apiClient: typeof api authHeaders: typeof SUPABASE_AUTH_HEADERS - authAdmin: Pick } export type TeamsRequestScope = RequestScope & { @@ -51,7 +48,6 @@ export function createTeamsRepository( deps: TeamsRepositoryDeps = { apiClient: api, authHeaders: SUPABASE_AUTH_HEADERS, - authAdmin, } ): TeamsRepository { return { @@ -79,29 +75,22 @@ export function createTeamsRepository( ) } - const members = data?.members ?? [] - const enrichedMembers = await Promise.all( - members.map(async (member) => { - const user = await deps.authAdmin.getUserById(member.id) - - return { - info: { - id: member.id, - email: member.email, - name: user?.name ?? undefined, - avatar_url: user?.avatarUrl ?? undefined, - providers: user?.providers ?? [], - createdAt: member.createdAt, - }, - relation: { - added_by: member.addedBy ?? null, - is_default: member.isDefault, - }, - } satisfies TeamMember - }) - ) - - return ok(enrichedMembers) + const mapped: TeamMember[] = (data?.members ?? []).map((member) => ({ + info: { + id: member.id, + email: member.email, + name: member.name ?? undefined, + avatar_url: member.profilePictureUrl ?? undefined, + providers: member.providers ?? [], + createdAt: member.createdAt, + }, + relation: { + added_by: member.addedBy ?? null, + is_default: member.isDefault, + }, + })) + + return ok(mapped) }, async updateTeamName( name diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 6cacda4f1..275514a77 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -4,7 +4,11 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_SERVER, + isGithubSignInDisabled, +} from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/core/server/actions/client' @@ -99,6 +103,8 @@ async function checkAuthProviderHealth(): Promise { const AUTH_PROVIDER_ERROR_MESSAGE = 'Our authentication provider is experiencing issues. Please try again later.' +const GITHUB_SIGN_IN_DISABLED_MESSAGE = + 'GitHub sign-in is temporarily paused while we migrate our authentication system. Please use another sign-in method.' const SignInWithOAuthInputSchema = z.object({ provider: z.union([z.literal('github'), z.literal('google')]), @@ -111,6 +117,16 @@ export const signInWithOAuthAction = actionClient .action(async ({ parsedInput }) => { const { provider, returnTo } = parsedInput + if (provider === 'github' && isGithubSignInDisabled()) { + const queryParams = returnTo ? { returnTo } : undefined + throw encodedRedirect( + 'error', + AUTH_URLS.SIGN_IN, + GITHUB_SIGN_IN_DISABLED_MESSAGE, + queryParams + ) + } + const isHealthy = await checkAuthProviderHealth() if (!isHealthy) { const queryParams = returnTo ? { returnTo } : undefined @@ -178,6 +194,12 @@ export const signUpAction = actionClient async ({ parsedInput: { email, password, returnTo = '', captchaToken }, }) => { + if (AUTH_MIGRATION_IN_PROGRESS) { + return returnServerError( + 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' + ) + } + const captchaError = await validateCaptcha(captchaToken) if (captchaError) return captchaError @@ -361,10 +383,32 @@ export const forgotPasswordAction = actionClient }) export async function signOutAction(returnTo?: string) { - await auth.signOut() + const { redirectTo } = await auth.signOut({ returnTo }) - throw redirect( - AUTH_URLS.SIGN_IN + - (returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '') - ) + throw redirect(redirectTo) +} + +// Drives the account-settings re-authentication step and returns the URL the +// client should HARD-navigate to. Supabase signs the user out and bounces +// through /sign-in (which lands back on the account page with ?reauth=1); Ory +// forces a fresh OAuth2 login via the oauth-start route. +// +// We deliberately return the URL instead of redirect()-ing: a server-action +// redirect is a soft RSC navigation, which prefetches and re-invokes the +// oauth-start GET (a side-effecting endpoint that mints OAuth state/pkce/ +// callback-url cookies). Those duplicate invocations corrupt the cookies so the +// post-reauth callback loses its callbackUrl and falls back to "/". A single +// window.location navigation on the client avoids that entirely. +export async function reauthForAccountSettingsAction(): Promise<{ + url: string +}> { + const dispatch = await auth.startReauthForAccountSettings() + + if (dispatch.kind === 'sign-out') { + // Supabase: clear the session server-side, then hand back the sign-in URL. + const { redirectTo } = await auth.signOut({ returnTo: dispatch.returnTo }) + return { url: redirectTo } + } + + return { url: dispatch.to } } diff --git a/src/core/server/actions/user-actions.ts b/src/core/server/actions/user-actions.ts deleted file mode 100644 index 78a97eb7b..000000000 --- a/src/core/server/actions/user-actions.ts +++ /dev/null @@ -1,140 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { headers } from 'next/headers' -import { returnValidationErrors } from 'next-safe-action' -import { z } from 'zod' -import { authActionClient } from '@/core/server/actions/client' -import { auth } from '@/core/server/auth' -import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { generateE2BUserAccessToken } from '@/lib/utils/server' - -const UpdateUserSchema = z - .object({ - email: z.email().optional(), - password: z.string().min(8).optional(), - name: z.string().min(1).max(100).optional(), - }) - .refine( - (data) => { - return Boolean(data.email || data.password || data.name) - }, - { - message: 'At least one field must be provided (email, password, name)', - path: [], - } - ) - -export type UpdateUserSchemaType = z.infer - -export const updateUserAction = authActionClient - .schema(UpdateUserSchema) - .metadata({ actionName: 'updateUser' }) - .action(async ({ parsedInput, ctx }) => { - const { user } = ctx - - // basic security check, that password does not equal e-mail - if (parsedInput.password) { - const passwordAsUserEmail = - parsedInput.password.toLowerCase() === user?.email?.toLowerCase() - const passwordAsEmail = - parsedInput.password.toLowerCase() === parsedInput.email?.toLowerCase() - - if (passwordAsUserEmail || passwordAsEmail) { - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - } - } - - const origin = (await headers()).get('origin') - - let emailRedirectTo: string | undefined - - if (parsedInput.email) { - if (!origin) { - throw new Error('Missing origin header for email update redirect') - } - - const redirectUrl = new URL('/api/auth/email-callback', origin) - redirectUrl.searchParams.set('new_email', parsedInput.email) - emailRedirectTo = redirectUrl.toString() - } - - const { data: updateData, error } = await supabaseAuthFlows.updateUser({ - email: parsedInput.email, - password: parsedInput.password, - name: parsedInput.name, - emailRedirectTo, - }) - - if (!error) { - // ensure other sessions are logged out if password was changed - if (parsedInput.password) { - const { error: signOutError } = await auth.signOut({ scope: 'others' }) - - if (signOutError) { - l.error( - { - key: 'update_user_action:sign_out_others_failed', - user_id: user.id, - error: serializeErrorForLog(signOutError), - }, - 'failed to invalidate other sessions after password change' - ) - } - } - - revalidatePath('/dashboard', 'layout') - - return { - user: updateData.user, - } - } - - switch (error?.code) { - case 'email_address_invalid': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['Invalid e-mail address.'], - }, - }) - case 'email_exists': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['E-mail already in use.'], - }, - }) - case 'same_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['New password cannot be the same as the old password.'], - }, - }) - case 'weak_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - case 'reauthentication_needed': - return { - requiresReauth: true, - } - default: - throw error - } - }) - -export const getUserAccessTokenAction = authActionClient - .metadata({ actionName: 'getUserAccessToken' }) - .action(async ({ ctx }) => { - const { session } = ctx - - const token = await generateE2BUserAccessToken(session.access_token) - - return token - }) diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts index 8c5cce9ed..e38e546d2 100644 --- a/src/core/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -6,6 +6,7 @@ import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' import { teamsRouter } from './teams' import { templatesRouter } from './templates' +import { userRouter } from './user' import { webhooksRouter } from './webhooks' export const trpcAppRouter = createTRPCRouter({ @@ -16,6 +17,7 @@ export const trpcAppRouter = createTRPCRouter({ billing: billingRouter, support: supportRouter, teams: teamsRouter, + user: userRouter, webhooks: webhooksRouter, }) diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts new file mode 100644 index 000000000..a9e386a82 --- /dev/null +++ b/src/core/server/api/routers/user.ts @@ -0,0 +1,136 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import type { AuthUser } from '@/core/server/auth' +import { createAuthForHeaders } from '@/core/server/auth' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedProcedure } from '@/core/server/trpc/procedures' +import { l } from '@/core/shared/clients/logger/logger' +import { generateE2BUserAccessToken } from '@/lib/utils/server' + +// How long the live identity-provider profile lookup is allowed to take before +// we fall back to the cheap session user. Keeps a slow Ory admin API out of the +// critical render path for every dashboard page. +const PROFILE_LOOKUP_TIMEOUT_MS = 3000 + +const UpdateUserSchema = z + .object({ + email: z.email().optional(), + password: z.string().min(8).optional(), + name: z.string().min(1).max(100).optional(), + }) + .refine((data) => Boolean(data.email || data.password || data.name), { + message: 'At least one field must be provided (email, password, name)', + path: [], + }) + +const TIMEOUT = Symbol('profile-lookup-timeout') + +function withTimeout( + promise: Promise, + ms: number +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => resolve(TIMEOUT), ms) + }), + ]) +} + +export const userRouter = createTRPCRouter({ + // Live profile (full traits + credential-derived providers). Prefetched once + // per dashboard load and injected into DashboardContext. The lookup is raced + // against a timeout and falls back to the cheap session user so the dashboard + // never hangs on the identity provider. + profile: protectedProcedure.query(async ({ ctx }): Promise => { + const provider = createAuthForHeaders(ctx.headers) + + const result = await withTimeout( + provider.getUserProfile().catch(() => null), + PROFILE_LOOKUP_TIMEOUT_MS + ) + + if (result && result !== TIMEOUT) { + return result + } + + l.error( + { + key: 'trpc_user_profile:fallback', + user_id: ctx.user.id, + context: { timed_out: result === TIMEOUT }, + }, + 'user profile lookup failed or timed out; falling back to session user' + ) + + return ctx.user + }), + + update: protectedProcedure + .input(UpdateUserSchema) + .mutation(async ({ ctx, input }) => { + // Basic security check: a password must not equal the account email + // (current or the new one being set in the same request). + if (input.password) { + const password = input.password.toLowerCase() + const matchesCurrentEmail = password === ctx.user.email?.toLowerCase() + const matchesNewEmail = + input.email !== undefined && password === input.email.toLowerCase() + + if (matchesCurrentEmail || matchesNewEmail) { + return { status: 'error' as const, code: 'weak_password' as const } + } + } + + const provider = createAuthForHeaders(ctx.headers) + + if (input.email !== undefined || input.password !== undefined) { + const profile = await provider.getUserProfile() + + if ( + !profile || + (input.email !== undefined && !profile.canChangeEmail) || + (input.password !== undefined && !profile.canChangePassword) + ) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + } + + const result = await provider.updateUser({ + email: input.email, + password: input.password, + name: input.name, + }) + + if (result.ok) { + // Invalidate other sessions when the password changed. + if (input.password) { + await provider.signOutOtherSessions() + } + + return { status: 'ok' as const, user: result.user } + } + + if (result.code === 'reauthentication_needed') { + return { status: 'reauth' as const } + } + + return { status: 'error' as const, code: result.code } + }), + + // Creates (POSTs) a fresh E2B access token — non-idempotent, fired on demand. + createAccessToken: protectedProcedure.mutation(async ({ ctx }) => { + try { + return await generateE2BUserAccessToken(ctx.session.access_token) + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to generate access token', + cause: error, + }) + } + }), +}) diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index c50bc63ae..c51029f10 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -1,9 +1,18 @@ import 'server-only' import type { NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l } from '@/core/shared/clients/logger/logger' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserInput, + UpdateUserResult, +} from '../types' export class OryHostedAuthProvider implements AuthProvider { constructor(private readonly cookie: string = '') {} @@ -21,14 +30,38 @@ export class OryHostedAuthProvider implements AuthProvider { return Promise.resolve(null) } + getUserProfile(): Promise { + return Promise.resolve(null) + } + signOut(_options?: SignOutOptions): Promise { return Promise.resolve({ + redirectTo: AUTH_URLS.SIGN_IN, error: { message: 'OryHostedAuthProvider.signOut is not implemented yet', code: 'ory_stub_not_implemented', }, }) } + + updateUser(_input: UpdateUserInput): Promise { + return Promise.resolve({ + ok: false, + code: 'account_credentials_not_changeable', + message: 'OryHostedAuthProvider.updateUser is not implemented yet', + }) + } + + startReauthForAccountSettings(): Promise { + return Promise.resolve({ + kind: 'sign-out', + returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS, + }) + } + + signOutOtherSessions(): Promise { + return Promise.resolve() + } } export function createOryAuthForProxy( diff --git a/src/core/server/auth/provider.ts b/src/core/server/auth/provider.ts index 6953cce2d..9fb435caa 100644 --- a/src/core/server/auth/provider.ts +++ b/src/core/server/auth/provider.ts @@ -1,6 +1,22 @@ -import type { AuthContext, SignOutOptions, SignOutResult } from './types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserInput, + UpdateUserResult, +} from './types' export interface AuthProvider { getAuthContext(): Promise + // Live profile lookup from the identity provider (Ory IdentityApi / Supabase + // getUser). Unlike getAuthContext's cheap session path, this carries the full + // traits and credential-derived providers. Heavier — call it once per + // dashboard load behind a cache, not on every request. + getUserProfile(): Promise signOut(options?: SignOutOptions): Promise + updateUser(input: UpdateUserInput): Promise + startReauthForAccountSettings(): Promise + signOutOtherSessions(): Promise } diff --git a/src/core/server/auth/supabase/flows.ts b/src/core/server/auth/supabase/flows.ts index b7351875e..468483224 100644 --- a/src/core/server/auth/supabase/flows.ts +++ b/src/core/server/auth/supabase/flows.ts @@ -20,13 +20,6 @@ type SignUpOptions = { data?: Record } -type UpdateUserOptions = { - email?: string - password?: string - name?: string - emailRedirectTo?: string -} - export const supabaseAuthFlows = { async signInWithOAuth({ provider, @@ -63,20 +56,6 @@ export const supabaseAuthFlows = { return client.auth.resetPasswordForEmail(email) }, - async updateUser({ - email, - password, - name, - emailRedirectTo, - }: UpdateUserOptions) { - const client = await createClient() - - return client.auth.updateUser( - { email, password, data: { name } }, - emailRedirectTo ? { emailRedirectTo } : undefined - ) - }, - async verifyOtp(...args: Parameters) { const client = await createClient() return client.auth.verifyOtp(...args) diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts index 47f65d24d..a506eebd9 100644 --- a/src/core/server/auth/supabase/provider.ts +++ b/src/core/server/auth/supabase/provider.ts @@ -1,10 +1,21 @@ import 'server-only' +import { headers } from 'next/headers' import type { NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { createClient } from '@/core/shared/clients/supabase/server' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserErrorCode, + UpdateUserInput, + UpdateUserResult, +} from '../types' import { createServerClientForHeaders, createServerClientForProxy, @@ -59,9 +70,31 @@ export class SupabaseAuthProvider implements AuthProvider { } } + async getUserProfile(): Promise { + const client = await this.resolveClient() + const { data, error } = await client.auth.getUser() + + if (error || !data.user) { + if (error) { + l.error( + { + key: 'auth_provider:get_user_profile:error', + error: serializeErrorForLog(error), + }, + `supabase getUser failed: ${error.message}` + ) + } + return null + } + + return toAuthUser(data.user) + } + async signOut(options?: SignOutOptions): Promise { const client = await this.resolveClient() - const { error } = await client.auth.signOut(options) + const { error } = await client.auth.signOut( + options?.scope ? { scope: options.scope } : undefined + ) if (error) { l.error( @@ -78,7 +111,59 @@ export class SupabaseAuthProvider implements AuthProvider { ) } - return { error: error ?? null } + return { + redirectTo: buildSignInRedirect(options?.returnTo), + error: error ?? null, + } + } + + async updateUser(input: UpdateUserInput): Promise { + const emailRedirectTo = input.email + ? await buildEmailVerificationRedirect(input.email) + : undefined + + const client = await this.resolveClient() + const { data, error } = await client.auth.updateUser( + { + email: input.email, + password: input.password, + data: { name: input.name }, + }, + emailRedirectTo ? { emailRedirectTo } : undefined + ) + + if (!error) { + return { ok: true, user: toAuthUser(data.user) } + } + + const code = mapSupabaseUpdateError(error.code) + // Preserve the original action behavior of throwing on unmapped errors so + // they surface as unexpected server errors. + if (!code) { + throw error + } + + return { ok: false, code, message: error.message } + } + + async startReauthForAccountSettings(): Promise { + return { kind: 'sign-out', returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS } + } + + async signOutOtherSessions(): Promise { + const client = await this.resolveClient() + const { error } = await client.auth.signOut({ scope: 'others' }) + + if (error) { + l.error( + { + key: 'auth_provider:sign_out_others:error', + error: serializeErrorForLog(error), + context: { error_code: error.code, error_status: error.status }, + }, + `supabase signOut(others) failed: ${error.message}` + ) + } } private resolveClient(): Promise { @@ -86,6 +171,36 @@ export class SupabaseAuthProvider implements AuthProvider { } } +async function buildEmailVerificationRedirect(email: string): Promise { + const origin = (await headers()).get('origin') + if (!origin) { + throw new Error('Missing origin header for email update redirect') + } + + const url = new URL('/api/auth/email-callback', origin) + url.searchParams.set('new_email', email) + return url.toString() +} + +function mapSupabaseUpdateError( + code: string | undefined +): UpdateUserErrorCode | null { + switch (code) { + case 'email_address_invalid': + return 'email_invalid' + case 'email_exists': + return 'email_exists' + case 'same_password': + return 'same_password' + case 'weak_password': + return 'weak_password' + case 'reauthentication_needed': + return 'reauthentication_needed' + default: + return null + } +} + export function createSupabaseAuthForProxy( request: NextRequest, response: NextResponse @@ -98,3 +213,9 @@ export function createSupabaseAuthForHeaders( ): SupabaseAuthProvider { return new SupabaseAuthProvider(createServerClientForHeaders(headers)) } + +function buildSignInRedirect(returnTo?: string): string { + if (!returnTo) return AUTH_URLS.SIGN_IN + const params = new URLSearchParams({ returnTo }) + return `${AUTH_URLS.SIGN_IN}?${params.toString()}` +} diff --git a/src/core/server/auth/supabase/user.ts b/src/core/server/auth/supabase/user.ts index a89ff9a64..2801e6b04 100644 --- a/src/core/server/auth/supabase/user.ts +++ b/src/core/server/auth/supabase/user.ts @@ -2,12 +2,17 @@ import type { User } from '@supabase/supabase-js' import type { AuthUser } from '../types' export function toAuthUser(user: User): AuthUser { + const providers = extractProviders(user) + const canChangeEmail = canChangeEmailPasswordSettings(providers) + return { id: user.id, email: user.email ?? null, name: getStringFromMetadata(user.user_metadata, 'name'), avatarUrl: getStringFromMetadata(user.user_metadata, 'avatar_url'), - providers: extractProviders(user), + providers, + canChangeEmail, + canChangePassword: canChangeEmail, } } @@ -34,3 +39,12 @@ function extractProviders(user: User): string[] { return [...new Set([...fromAppMetadata, ...fromIdentities])] } + +function canChangeEmailPasswordSettings(providers: string[]): boolean { + return ( + providers.includes('email') && + providers.every((provider) => { + return provider === 'email' + }) + ) +} diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index c6520b495..582b5163f 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -4,6 +4,8 @@ export type AuthUser = { name: string | null avatarUrl: string | null providers: string[] + canChangeEmail: boolean + canChangePassword: boolean } export type AuthContext = { @@ -13,6 +15,7 @@ export type AuthContext = { export type SignOutOptions = { scope?: 'local' | 'others' | 'global' + returnTo?: string } export type AuthError = { @@ -21,4 +24,34 @@ export type AuthError = { status?: number } -export type SignOutResult = { error: AuthError | null } +export type SignOutResult = { + redirectTo: string + error?: AuthError | null +} + +export type UpdateUserInput = { + name?: string + email?: string + password?: string +} + +// Expected, user-facing update failures. Anything else throws and is handled +// as an unexpected server error by the action client. +export type UpdateUserErrorCode = + | 'email_exists' + | 'email_invalid' + | 'weak_password' + | 'same_password' + | 'reauthentication_needed' + | 'account_credentials_not_changeable' + +export type UpdateUserResult = + | { ok: true; user: AuthUser } + | { ok: false; code: UpdateUserErrorCode; message?: string } + +// How the caller should drive the account-settings re-authentication step. +// Supabase signs the user out and bounces through /sign-in; Ory forces a +// fresh OAuth2 login via the oauth-start route. +export type ReauthDispatch = + | { kind: 'sign-out'; returnTo: string } + | { kind: 'redirect'; to: string } diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index e49242d32..13cb8f9c3 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -1,7 +1,11 @@ import 'server-cli-only' import { type NextRequest, NextResponse } from 'next/server' +import { ALLOW_SEO_INDEXING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { createAuthForProxy } from '@/core/server/auth' +import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' +import { getRewriteForPath } from '@/lib/utils/rewrites' export function isAuthRoute(pathname: string): boolean { return ( @@ -45,3 +49,80 @@ export function getAuthRedirect( return null } + +// The handlers below are the ordered concerns the proxy runs for every request. +// Each returns a Response when it handles the request, or null to fall through +// to the next concern. They live here (not in static next.config matchers) +// because they need custom headers / runtime path logic. + +// Redirects that require custom response headers. +export function handleMiddlewareRedirect( + request: NextRequest +): NextResponse | null { + const redirect = getMiddlewareRedirectFromPath(request.nextUrl.pathname) + if (!redirect) return null + + return NextResponse.redirect(new URL(redirect.destination, request.url), { + status: redirect.statusCode, + headers: new Headers(redirect.headers), + }) +} + +// Catch-all route rewrites are resolved by the route itself, so the proxy just +// passes them through untouched. +export function handleRouteRewritePassthrough( + request: NextRequest +): NextResponse | null { + const { config } = getRewriteForPath(request.nextUrl.pathname, 'route') + return config ? NextResponse.next({ request }) : null +} + +// Rewrites the proxy performs itself (serving another origin under our domain), +// tagging the request/response with the SEO-indexing intent. +export function handleMiddlewareRewrite( + request: NextRequest +): NextResponse | null { + const { config, rule } = getRewriteForPath( + request.nextUrl.pathname, + 'middleware' + ) + if (!config) return null + + const rewriteUrl = new URL(request.url) + rewriteUrl.hostname = config.domain + rewriteUrl.protocol = 'https' + rewriteUrl.port = '' + if (rule?.pathPreprocessor) { + rewriteUrl.pathname = rule.pathPreprocessor(rewriteUrl.pathname) + } + + const requestHeaders = new Headers(request.headers) + if (ALLOW_SEO_INDEXING) { + requestHeaders.set('x-e2b-should-index', '1') + } + + const response = NextResponse.rewrite(rewriteUrl, { + request: { headers: requestHeaders }, + }) + response.headers.set( + 'X-Robots-Tag', + ALLOW_SEO_INDEXING ? 'index, follow' : 'noindex, nofollow' + ) + return response +} + +// Terminal concern: gate dashboard/auth routes on authentication. `knownAuth` +// is supplied in Ory mode (resolved by the Auth.js middleware wrapper); in +// Supabase mode it's resolved here from the request/response cookies. +export async function handleAuthGate( + request: NextRequest, + knownAuth?: boolean +): Promise { + const response = NextResponse.next({ request }) + + const isAuthenticated = + knownAuth ?? + !!(await createAuthForProxy(request, response).getAuthContext()) + + return getAuthRedirect(request, isAuthenticated) ?? response +} diff --git a/src/features/auth/oauth-provider-buttons.tsx b/src/features/auth/oauth-provider-buttons.tsx index 4f6dc6f38..dd1bf55e8 100644 --- a/src/features/auth/oauth-provider-buttons.tsx +++ b/src/features/auth/oauth-provider-buttons.tsx @@ -2,6 +2,7 @@ import { useSearchParams } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' +import { AUTH_GITHUB_SIGN_IN_DISABLED } from '@/configs/flags' import { signInWithOAuthAction } from '@/core/server/actions/auth-actions' import { Button } from '@/ui/primitives/button' @@ -21,7 +22,12 @@ export function OAuthProviders() { className="flex items-center gap-2" disabled={isTransitioning} > - +

- Has to be a valid e-mail address. -

- - - - - + <> +
+ + + + E-Mail + + {canChangeEmail + ? 'Update your e-mail address.' + : 'E-mail changes are currently unavailable.'} + + + + + ( + + + + + + + )} + /> + + + +

+ Has to be a valid e-mail address. +

+ +
+
+
+ + + + ) } diff --git a/src/features/dashboard/account/name-settings.tsx b/src/features/dashboard/account/name-settings.tsx index 44cb659f5..4bd493eec 100644 --- a/src/features/dashboard/account/name-settings.tsx +++ b/src/features/dashboard/account/name-settings.tsx @@ -1,17 +1,17 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -49,6 +49,8 @@ export function NameSettings({ className }: NameSettingsProps) { const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() const form = useForm({ resolver: zodResolver(formSchema), @@ -60,18 +62,22 @@ export function NameSettings({ className }: NameSettingsProps) { }, }) - const { execute: updateName, isPending } = useAction(updateUserAction, { - onSuccess: async () => { - toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateName.message - ) - ) - }, - }) + const { mutate: updateName, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'ok') { + queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) + toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) + return + } + + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + }) + ) if (!user) return null diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx index 6f240c90f..0423f094c 100644 --- a/src/features/dashboard/account/password-settings.tsx +++ b/src/features/dashboard/account/password-settings.tsx @@ -1,18 +1,18 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' -import { useEffect, useMemo, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -61,6 +61,7 @@ export function PasswordSettings({ const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() const [reauthDialogOpen, setReauthDialogOpen] = useState(false) const [clientShowPasswordForm, setClientShowPasswordForm] = useState( showPasswordChangeForm @@ -70,11 +71,6 @@ export function PasswordSettings({ setClientShowPasswordForm(showPasswordChangeForm) }, [showPasswordChangeForm]) - const hasEmailProvider = useMemo( - () => user.providers.includes('email'), - [user] - ) - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -83,33 +79,39 @@ export function PasswordSettings({ }, }) - const { execute: updatePassword, isPending } = useAction(updateUserAction, { - onSuccess: ({ data }) => { - if (data?.requiresReauth) { - setReauthDialogOpen(true) - return - } - - toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) - - form.reset() - setClientShowPasswordForm(false) - window.history.replaceState({}, '', window.location.pathname) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors?.password) { - form.setError('confirmPassword', { - message: error.validationErrors.fieldErrors.password?.[0], - }) - } else { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdatePassword.message - ) - ) - } - }, - }) + const { mutate: updatePassword, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'reauth') { + setReauthDialogOpen(true) + return + } + + if (data.status === 'error') { + if (data.code === 'account_credentials_not_changeable') { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + return + } + + const message = + data.code === 'same_password' + ? 'New password cannot be the same as the old password.' + : 'Password is too weak.' + form.setError('confirmPassword', { message }) + return + } + + toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) + + form.reset() + setClientShowPasswordForm(false) + window.history.replaceState({}, '', window.location.pathname) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + }, + }) + ) function onSubmit(values: FormValues) { updatePassword({ password: values.password }) @@ -119,7 +121,7 @@ export function PasswordSettings({ setReauthDialogOpen(true) } - if (!user || !hasEmailProvider) return null + if (!user || !user.canChangePassword) return null return ( <> diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx index c5d322709..6e15f4061 100644 --- a/src/features/dashboard/account/reauth-dialog.tsx +++ b/src/features/dashboard/account/reauth-dialog.tsx @@ -1,7 +1,6 @@ 'use client' -import { PROTECTED_URLS } from '@/configs/urls' -import { signOutAction } from '@/core/server/actions/auth-actions' +import { reauthForAccountSettingsAction } from '@/core/server/actions/auth-actions' import { AlertDialog } from '@/ui/alert-dialog' interface ReauthDialogProps { @@ -10,8 +9,12 @@ interface ReauthDialogProps { } export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { - const handleReauth = () => { - signOutAction(PROTECTED_URLS.ACCOUNT_SETTINGS) + const handleReauth = async () => { + // Hard navigation (not the Next router): oauth-start is a side-effecting GET + // that must run exactly once, so a soft RSC navigation would corrupt the + // OAuth flow. See reauthForAccountSettingsAction. + const { url } = await reauthForAccountSettingsAction() + window.location.href = url } return ( @@ -21,8 +24,8 @@ export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { title="Re-authentication Required" description={

- To change your password, you'll need to{' '} - re-authenticate for security. + To make this change, you'll need to re-authenticate{' '} + for security.

} confirm="Sign in again" diff --git a/src/features/dashboard/account/user-access-token.tsx b/src/features/dashboard/account/user-access-token.tsx index 433b26c22..23ea04127 100644 --- a/src/features/dashboard/account/user-access-token.tsx +++ b/src/features/dashboard/account/user-access-token.tsx @@ -1,9 +1,9 @@ 'use client' -import { useAction } from 'next-safe-action/hooks' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' -import { getUserAccessTokenAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' import CopyButton from '@/ui/copy-button' import { IconButton } from '@/ui/primitives/icon-button' import { EyeIcon, EyeOffIcon } from '@/ui/primitives/icons' @@ -16,22 +16,22 @@ interface UserAccessTokenProps { export default function UserAccessToken({ className }: UserAccessTokenProps) { const { toast } = useToast() + const trpc = useTRPC() const [token, setToken] = useState() const [isVisible, setIsVisible] = useState(false) - const { execute: fetchToken, isPending } = useAction( - getUserAccessTokenAction, - { - onSuccess: (result) => { - if (result.data) { - setToken(result.data.token) + const { mutate: fetchToken, isPending } = useMutation( + trpc.user.createAccessToken.mutationOptions({ + onSuccess: (data) => { + if (data?.token) { + setToken(data.token) setIsVisible(true) } }, onError: () => { toast(defaultErrorToast('Failed to fetch access token')) }, - } + }) ) return ( diff --git a/src/lib/env.ts b/src/lib/env.ts index ab016be32..eb79dad07 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -59,6 +59,8 @@ export const clientSchema = z.object({ NEXT_PUBLIC_SCAN: z.string().optional(), NEXT_PUBLIC_MOCK_DATA: z.string().optional(), NEXT_PUBLIC_VERBOSE: z.string().optional(), + NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS: z.string().optional(), + NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED: z.string().optional(), NEXT_PUBLIC_CAPTCHA_ENABLED: z.string().optional(), NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(), diff --git a/src/proxy.ts b/src/proxy.ts index 98aa919f7..2b22904a6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,93 +1,22 @@ import { type NextRequest, NextResponse } from 'next/server' -import { ALLOW_SEO_INDEXING } from './configs/flags' -import { createAuthForProxy } from './core/server/auth' -import { getAuthRedirect } from './core/server/http/proxy' +import { + handleAuthGate, + handleMiddlewareRedirect, + handleMiddlewareRewrite, + handleRouteRewritePassthrough, +} from './core/server/http/proxy' import { l, serializeErrorForLog } from './core/shared/clients/logger/logger' -import { getMiddlewareRedirectFromPath } from './lib/utils/redirects' -import { getRewriteForPath } from './lib/utils/rewrites' -export async function proxy(request: NextRequest) { +// Runs the proxy's ordered concerns: the first handler that returns a Response +// wins; otherwise we fall through to the auth gate. +async function proxyCore(request: NextRequest): Promise { try { - const pathname = request.nextUrl.pathname - - // Redirects, that require custom headers - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const middlewareRedirect = getMiddlewareRedirectFromPath( - request.nextUrl.pathname + return ( + handleMiddlewareRedirect(request) ?? + handleRouteRewritePassthrough(request) ?? + handleMiddlewareRewrite(request) ?? + (await handleAuthGate(request)) ) - - if (middlewareRedirect) { - const headers = new Headers(middlewareRedirect.headers) - const url = new URL(middlewareRedirect.destination, request.url) - - return NextResponse.redirect(url, { - status: middlewareRedirect.statusCode, - headers, - }) - } - - // Catch-all route rewrite paths should not be handled by middleware - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const { config: routeRewriteConfig } = getRewriteForPath(pathname, 'route') - - if (routeRewriteConfig) { - return NextResponse.next({ - request, - }) - } - - // Check if the path should be rewritten by middleware - const { config: middlewareRewriteConfig, rule: middlewareRewriteRule } = - getRewriteForPath(pathname, 'middleware') - - if (middlewareRewriteConfig) { - const rewriteUrl = new URL(request.url) - rewriteUrl.hostname = middlewareRewriteConfig.domain - rewriteUrl.protocol = 'https' - rewriteUrl.port = '' - if (middlewareRewriteRule?.pathPreprocessor) { - rewriteUrl.pathname = middlewareRewriteRule.pathPreprocessor( - rewriteUrl.pathname - ) - } - - const headers = new Headers(request.headers) - - if (ALLOW_SEO_INDEXING) { - headers.set('x-e2b-should-index', '1') - } - - const response = NextResponse.rewrite(rewriteUrl, { - request: { - headers, - }, - }) - - if (ALLOW_SEO_INDEXING) { - response.headers.set('X-Robots-Tag', 'index, follow') - } else { - response.headers.set('X-Robots-Tag', 'noindex, nofollow') - } - - return response - } - - const response = NextResponse.next({ - request, - }) - const authContext = await createAuthForProxy( - request, - response - ).getAuthContext() - const isAuthenticated = !!authContext - - const authRedirect = getAuthRedirect(request, isAuthenticated) - - if (authRedirect) { - return authRedirect - } - - return response } catch (error) { l.error( { @@ -108,6 +37,10 @@ export async function proxy(request: NextRequest) { } } +export async function proxy(request: NextRequest) { + return proxyCore(request) +} + export const config = { matcher: [ /* diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 5871a8e1c..e37cc7284 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -106,6 +106,7 @@ describe('Auth Actions - Integration Tests', () => { }) afterEach(() => { + vi.unstubAllEnvs() // Restore original console.error after each test console.error = originalConsoleError global.fetch = originalFetch @@ -459,6 +460,24 @@ describe('Auth Actions - Integration Tests', () => { }, }) }) + + it('should block GitHub OAuth when the migration flag is enabled', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED', '1') + + await signInWithOAuthAction({ + provider: 'github', + returnTo: '/dashboard/team-123', + }) + + expect(encodedRedirect).toHaveBeenCalledWith( + 'error', + AUTH_URLS.SIGN_IN, + 'GitHub sign-in is temporarily paused while we migrate our authentication system. Please use another sign-in method.', + { returnTo: '/dashboard/team-123' } + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(mockSupabaseClient.auth.signInWithOAuth).not.toHaveBeenCalled() + }) }) describe('Sign Out Flow', () => { diff --git a/tests/unit/auth-supabase-provider.test.ts b/tests/unit/auth-supabase-provider.test.ts index 690b7a866..468772127 100644 --- a/tests/unit/auth-supabase-provider.test.ts +++ b/tests/unit/auth-supabase-provider.test.ts @@ -150,7 +150,7 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut({ scope: 'others' }) - expect(result).toEqual({ error: signOutError }) + expect(result).toEqual({ redirectTo: '/sign-in', error: signOutError }) expect(loggerMocks.error).toHaveBeenCalledWith( expect.objectContaining({ key: 'auth_provider:sign_out:error', @@ -165,7 +165,7 @@ describe('SupabaseAuthProvider', () => { ) }) - it('returns { error: null } on success without logging', async () => { + it('returns the sign-in redirect with null error on success without logging', async () => { const client = buildClient({ signOut: vi.fn().mockResolvedValue({ error: null }), }) @@ -173,8 +173,24 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut() - expect(result).toEqual({ error: null }) + expect(result).toEqual({ redirectTo: '/sign-in', error: null }) expect(loggerMocks.error).not.toHaveBeenCalled() }) + + it('preserves returnTo as a sign-in query param for the reauth flow', async () => { + const client = buildClient({ + signOut: vi.fn().mockResolvedValue({ error: null }), + }) + const provider = new SupabaseAuthProvider(client) + + const result = await provider.signOut({ + returnTo: '/dashboard/account', + }) + + expect(result).toEqual({ + redirectTo: '/sign-in?returnTo=%2Fdashboard%2Faccount', + error: null, + }) + }) }) }) diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts new file mode 100644 index 000000000..e2e4aff17 --- /dev/null +++ b/tests/unit/proxy-handlers.test.ts @@ -0,0 +1,82 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getMiddlewareRedirectMock = vi.hoisted(() => vi.fn()) +const getRewriteForPathMock = vi.hoisted(() => vi.fn()) +const createAuthForProxyMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/configs/flags', () => ({ ALLOW_SEO_INDEXING: false })) + +vi.mock('@/lib/utils/redirects', () => ({ + getMiddlewareRedirectFromPath: getMiddlewareRedirectMock, +})) + +vi.mock('@/lib/utils/rewrites', () => ({ + getRewriteForPath: getRewriteForPathMock, +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForProxy: createAuthForProxyMock, +})) + +const { + handleMiddlewareRedirect, + handleRouteRewritePassthrough, + handleMiddlewareRewrite, + handleAuthGate, +} = await import('@/core/server/http/proxy') + +function request(path: string): NextRequest { + return new NextRequest(`https://app.e2b.dev${path}`) +} + +beforeEach(() => { + getMiddlewareRedirectMock.mockReset().mockReturnValue(undefined) + getRewriteForPathMock.mockReset().mockReturnValue({ config: undefined }) + createAuthForProxyMock.mockReset() +}) + +describe('proxy handlers', () => { + it('redirects with the configured status and headers', () => { + getMiddlewareRedirectMock.mockReturnValue({ + destination: '/new-home', + statusCode: 308, + headers: { 'x-custom': 'yes' }, + }) + + const response = handleMiddlewareRedirect(request('/old-home')) + + expect(response?.status).toBe(308) + expect(response?.headers.get('location')).toContain('/new-home') + }) + + it('passes catch-all route rewrites through untouched', () => { + getRewriteForPathMock.mockReturnValue({ config: { domain: 'x' } }) + + const response = handleRouteRewritePassthrough(request('/docs')) + + expect(response).not.toBeNull() + expect(response?.headers.get('location')).toBeNull() + }) + + it('rewrites middleware-managed paths to the configured origin', () => { + getRewriteForPathMock.mockReturnValue({ + config: { domain: 'docs.e2b.dev' }, + rule: { pathPreprocessor: (p: string) => p.replace('/docs', '') }, + }) + + const response = handleMiddlewareRewrite(request('/docs/guide')) + + expect(response?.headers.get('x-middleware-rewrite')).toContain( + 'docs.e2b.dev/guide' + ) + expect(response?.headers.get('X-Robots-Tag')).toBe('noindex, nofollow') + }) + + it('uses provided auth state without resolving auth again', async () => { + const response = await handleAuthGate(request('/dashboard/team-x'), false) + + expect(response.headers.get('location')).toContain('/sign-in') + expect(createAuthForProxyMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/teams-repository.test.ts b/tests/unit/teams-repository.test.ts index 493d2951d..f9f9a77a3 100644 --- a/tests/unit/teams-repository.test.ts +++ b/tests/unit/teams-repository.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -vi.mock('@/core/shared/clients/supabase/admin', () => ({ - supabaseAdmin: { - auth: { - admin: { - getUserById: vi.fn(), - }, - }, - }, -})) - describe('createTeamsRepository', () => { it('returns a repo error instead of throwing when a team-scoped method has no teamId', async () => { const repository = createTeamsRepository( @@ -23,9 +13,6 @@ describe('createTeamsRepository', () => { DELETE: vi.fn(), } as unknown as typeof import('@/core/shared/clients/api').api, authHeaders: vi.fn(() => ({ 'X-Supabase-Token': 'token' })), - authAdmin: { - getUserById: vi.fn(), - }, } ) diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts new file mode 100644 index 000000000..4b221f1fc --- /dev/null +++ b/tests/unit/user-router.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +const providerMock = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + getUserProfile: vi.fn(), + updateUser: vi.fn(), + signOut: vi.fn(), + startReauthForAccountSettings: vi.fn(), + signOutOtherSessions: vi.fn(), +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForHeaders: vi.fn(() => providerMock), +})) + +vi.mock('@/lib/utils/server', () => ({ + generateE2BUserAccessToken: vi.fn(), +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { userRouter } = await import('@/core/server/api/routers/user') + +const createCaller = createCallerFactory(userRouter) + +const authUser = { + id: 'user-1', + email: 'old@example.test', + name: 'Ada', + avatarUrl: null, + providers: ['email'], + canChangeEmail: false, + canChangePassword: true, +} + +describe('userRouter.update', () => { + beforeEach(() => { + providerMock.getAuthContext.mockResolvedValue({ + user: authUser, + accessToken: 'access-token', + }) + providerMock.getUserProfile.mockReset() + providerMock.updateUser.mockReset() + }) + + it('denies email changes when the provider profile says they are not changeable', async () => { + providerMock.getUserProfile.mockResolvedValue(authUser) + + const ctx = await createTRPCContext({ headers: new Headers() }) + const caller = createCaller(ctx) + + const result = await caller.update({ email: 'new@example.test' }) + + expect(result).toEqual({ + status: 'error', + code: 'account_credentials_not_changeable', + }) + expect(providerMock.getUserProfile).toHaveBeenCalled() + expect(providerMock.updateUser).not.toHaveBeenCalled() + }) +}) From 33a330ee74d4a59077ae04931a8f229af763cfa4 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 13:36:57 -0700 Subject: [PATCH 2/7] fix: pause sign-ins during auth migration --- .env.example | 3 +- src/app/(auth)/sign-in/login-form.tsx | 7 ++++ src/configs/flags.ts | 13 ++++--- src/core/server/actions/auth-actions.ts | 26 +++++++++++--- src/features/auth/oauth-provider-buttons.tsx | 26 ++++++++++---- tests/integration/auth.test.ts | 36 +++++++++++++++++++- 6 files changed, 92 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 578f53cd8..3c8f33cf1 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Set to 1 to enable verbose logging # NEXT_PUBLIC_VERBOSE=0 -### Set to 1 to pause new sign-ups during auth migration. -### Existing users can still sign in and invite team members. +### Set to 1 to pause sign-ups and sign-ins during auth migration. # NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS=0 ### Set to 1 to temporarily disable GitHub OAuth sign-ins during auth migration cutover. diff --git a/src/app/(auth)/sign-in/login-form.tsx b/src/app/(auth)/sign-in/login-form.tsx index 6da5d319f..af160fad8 100644 --- a/src/app/(auth)/sign-in/login-form.tsx +++ b/src/app/(auth)/sign-in/login-form.tsx @@ -5,6 +5,7 @@ import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hoo import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' +import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { AUTH_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { signInAction } from '@/core/server/actions/auth-actions' @@ -152,6 +153,12 @@ export default function Login() { diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 879be4141..41e7ea90f 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -30,11 +30,14 @@ export function isOryAuthEnabled() { return process.env.AUTH_PROVIDER === 'ory' } -// Freezes new identity creation while we migrate identity stores. -// When on: blocks new sign-ups (email/password + freshly-registered OIDC -// identities). Existing users keep signing in normally. -export const AUTH_MIGRATION_IN_PROGRESS = - process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' +// Freezes interactive auth entry points while we migrate identity stores. +// When on: blocks sign-ups and sign-ins so OAuth callback URLs can move +// between providers without sending users into broken provider flows. +export function isAuthMigrationInProgress() { + return process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' +} + +export const AUTH_MIGRATION_IN_PROGRESS = isAuthMigrationInProgress() // Temporarily disables GitHub OAuth entry points during auth provider cutovers, // when GitHub's callback URL may need to move between providers. diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 275514a77..cdb363300 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -5,8 +5,8 @@ import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' import { - AUTH_MIGRATION_IN_PROGRESS, CAPTCHA_REQUIRED_SERVER, + isAuthMigrationInProgress, isGithubSignInDisabled, } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' @@ -103,6 +103,10 @@ async function checkAuthProviderHealth(): Promise { const AUTH_PROVIDER_ERROR_MESSAGE = 'Our authentication provider is experiencing issues. Please try again later.' +const AUTH_MIGRATION_SIGN_IN_DISABLED_MESSAGE = + 'Sign-ins are temporarily paused while we migrate our authentication system. Please try again later.' +const AUTH_MIGRATION_SIGN_UP_DISABLED_MESSAGE = + 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' const GITHUB_SIGN_IN_DISABLED_MESSAGE = 'GitHub sign-in is temporarily paused while we migrate our authentication system. Please use another sign-in method.' @@ -117,6 +121,16 @@ export const signInWithOAuthAction = actionClient .action(async ({ parsedInput }) => { const { provider, returnTo } = parsedInput + if (isAuthMigrationInProgress()) { + const queryParams = returnTo ? { returnTo } : undefined + throw encodedRedirect( + 'error', + AUTH_URLS.SIGN_IN, + AUTH_MIGRATION_SIGN_IN_DISABLED_MESSAGE, + queryParams + ) + } + if (provider === 'github' && isGithubSignInDisabled()) { const queryParams = returnTo ? { returnTo } : undefined throw encodedRedirect( @@ -194,10 +208,8 @@ export const signUpAction = actionClient async ({ parsedInput: { email, password, returnTo = '', captchaToken }, }) => { - if (AUTH_MIGRATION_IN_PROGRESS) { - return returnServerError( - 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' - ) + if (isAuthMigrationInProgress()) { + return returnServerError(AUTH_MIGRATION_SIGN_UP_DISABLED_MESSAGE) } const captchaError = await validateCaptcha(captchaToken) @@ -299,6 +311,10 @@ export const signInAction = actionClient .schema(signInSchema) .metadata({ actionName: 'signInWithEmailAndPassword' }) .action(async ({ parsedInput: { email, password, returnTo = '' } }) => { + if (isAuthMigrationInProgress()) { + return returnServerError(AUTH_MIGRATION_SIGN_IN_DISABLED_MESSAGE) + } + const isHealthy = await checkAuthProviderHealth() if (!isHealthy) { const queryParams = returnTo ? { returnTo } : undefined diff --git a/src/features/auth/oauth-provider-buttons.tsx b/src/features/auth/oauth-provider-buttons.tsx index dd1bf55e8..1f30c8b5c 100644 --- a/src/features/auth/oauth-provider-buttons.tsx +++ b/src/features/auth/oauth-provider-buttons.tsx @@ -2,7 +2,10 @@ import { useSearchParams } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' -import { AUTH_GITHUB_SIGN_IN_DISABLED } from '@/configs/flags' +import { + AUTH_GITHUB_SIGN_IN_DISABLED, + AUTH_MIGRATION_IN_PROGRESS, +} from '@/configs/flags' import { signInWithOAuthAction } from '@/core/server/actions/auth-actions' import { Button } from '@/ui/primitives/button' @@ -11,6 +14,10 @@ export function OAuthProviders() { const returnTo = searchParams.get('returnTo') const { execute, isTransitioning } = useAction(signInWithOAuthAction) + const isOAuthDisabled = + isTransitioning || + AUTH_MIGRATION_IN_PROGRESS || + AUTH_GITHUB_SIGN_IN_DISABLED return (
@@ -20,7 +27,12 @@ export function OAuthProviders() { execute({ provider: 'google', returnTo: returnTo || undefined }) } className="flex items-center gap-2" - disabled={isTransitioning} + disabled={isTransitioning || AUTH_MIGRATION_IN_PROGRESS} + title={ + AUTH_MIGRATION_IN_PROGRESS + ? 'Sign-ins are temporarily paused' + : undefined + } > { expect(redirect).toHaveBeenCalledWith('/dashboard/team-123/sandboxes') }) + it('should block password sign-in while auth migration is in progress', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS', '1') + + const result = await signInAction({ + email: 'test@example.com', + password: 'password123', + returnTo: '/dashboard/team-123', + }) + + expect(result?.serverError).toBe( + 'Sign-ins are temporarily paused while we migrate our authentication system. Please try again later.' + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(mockSupabaseClient.auth.signInWithPassword).not.toHaveBeenCalled() + }) + it('should throw validation error if returnTo is not a relative path', async () => { mockSupabaseClient.auth.signInWithPassword.mockResolvedValue({ data: { user: { id: 'user-123' } }, @@ -461,7 +477,25 @@ describe('Auth Actions - Integration Tests', () => { }) }) - it('should block GitHub OAuth when the migration flag is enabled', async () => { + it('should block OAuth sign-in while auth migration is in progress', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS', '1') + + await signInWithOAuthAction({ + provider: 'google', + returnTo: '/dashboard/team-123', + }) + + expect(encodedRedirect).toHaveBeenCalledWith( + 'error', + AUTH_URLS.SIGN_IN, + 'Sign-ins are temporarily paused while we migrate our authentication system. Please try again later.', + { returnTo: '/dashboard/team-123' } + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(mockSupabaseClient.auth.signInWithOAuth).not.toHaveBeenCalled() + }) + + it('should block GitHub OAuth when the GitHub sign-in disabled flag is enabled', async () => { vi.stubEnv('NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED', '1') await signInWithOAuthAction({ From e0ed29eded1f4eb03f25795e7253521de781165e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 13:40:46 -0700 Subject: [PATCH 3/7] chore: remove github-only auth migration flag --- .env.example | 3 --- src/configs/flags.ts | 8 -------- src/core/server/actions/auth-actions.ts | 13 ------------- src/features/auth/oauth-provider-buttons.tsx | 18 ++++-------------- src/lib/env.ts | 1 - tests/integration/auth.test.ts | 18 ------------------ 6 files changed, 4 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 3c8f33cf1..ec4447f9c 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,3 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Set to 1 to pause sign-ups and sign-ins during auth migration. # NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS=0 - -### Set to 1 to temporarily disable GitHub OAuth sign-ins during auth migration cutover. -# NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED=0 diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 41e7ea90f..e9cbf269a 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -38,11 +38,3 @@ export function isAuthMigrationInProgress() { } export const AUTH_MIGRATION_IN_PROGRESS = isAuthMigrationInProgress() - -// Temporarily disables GitHub OAuth entry points during auth provider cutovers, -// when GitHub's callback URL may need to move between providers. -export function isGithubSignInDisabled() { - return process.env.NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED === '1' -} - -export const AUTH_GITHUB_SIGN_IN_DISABLED = isGithubSignInDisabled() diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index cdb363300..7966599c1 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -7,7 +7,6 @@ import { z } from 'zod' import { CAPTCHA_REQUIRED_SERVER, isAuthMigrationInProgress, - isGithubSignInDisabled, } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' @@ -107,8 +106,6 @@ const AUTH_MIGRATION_SIGN_IN_DISABLED_MESSAGE = 'Sign-ins are temporarily paused while we migrate our authentication system. Please try again later.' const AUTH_MIGRATION_SIGN_UP_DISABLED_MESSAGE = 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' -const GITHUB_SIGN_IN_DISABLED_MESSAGE = - 'GitHub sign-in is temporarily paused while we migrate our authentication system. Please use another sign-in method.' const SignInWithOAuthInputSchema = z.object({ provider: z.union([z.literal('github'), z.literal('google')]), @@ -131,16 +128,6 @@ export const signInWithOAuthAction = actionClient ) } - if (provider === 'github' && isGithubSignInDisabled()) { - const queryParams = returnTo ? { returnTo } : undefined - throw encodedRedirect( - 'error', - AUTH_URLS.SIGN_IN, - GITHUB_SIGN_IN_DISABLED_MESSAGE, - queryParams - ) - } - const isHealthy = await checkAuthProviderHealth() if (!isHealthy) { const queryParams = returnTo ? { returnTo } : undefined diff --git a/src/features/auth/oauth-provider-buttons.tsx b/src/features/auth/oauth-provider-buttons.tsx index 1f30c8b5c..39b611c52 100644 --- a/src/features/auth/oauth-provider-buttons.tsx +++ b/src/features/auth/oauth-provider-buttons.tsx @@ -2,10 +2,7 @@ import { useSearchParams } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' -import { - AUTH_GITHUB_SIGN_IN_DISABLED, - AUTH_MIGRATION_IN_PROGRESS, -} from '@/configs/flags' +import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { signInWithOAuthAction } from '@/core/server/actions/auth-actions' import { Button } from '@/ui/primitives/button' @@ -14,10 +11,7 @@ export function OAuthProviders() { const returnTo = searchParams.get('returnTo') const { execute, isTransitioning } = useAction(signInWithOAuthAction) - const isOAuthDisabled = - isTransitioning || - AUTH_MIGRATION_IN_PROGRESS || - AUTH_GITHUB_SIGN_IN_DISABLED + const isOAuthDisabled = isTransitioning || AUTH_MIGRATION_IN_PROGRESS return (
@@ -70,9 +64,7 @@ export function OAuthProviders() { title={ AUTH_MIGRATION_IN_PROGRESS ? 'Sign-ins are temporarily paused' - : AUTH_GITHUB_SIGN_IN_DISABLED - ? 'GitHub sign-in is temporarily paused' - : undefined + : undefined } > - {AUTH_GITHUB_SIGN_IN_DISABLED - ? 'GitHub sign-in paused' - : 'Continue with GitHub'} + Continue with GitHub
) diff --git a/src/lib/env.ts b/src/lib/env.ts index eb79dad07..d9a047222 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -60,7 +60,6 @@ export const clientSchema = z.object({ NEXT_PUBLIC_MOCK_DATA: z.string().optional(), NEXT_PUBLIC_VERBOSE: z.string().optional(), NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS: z.string().optional(), - NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED: z.string().optional(), NEXT_PUBLIC_CAPTCHA_ENABLED: z.string().optional(), NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().optional(), diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 2d6a8db18..263c70d2b 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -494,24 +494,6 @@ describe('Auth Actions - Integration Tests', () => { expect(fetchMock).not.toHaveBeenCalled() expect(mockSupabaseClient.auth.signInWithOAuth).not.toHaveBeenCalled() }) - - it('should block GitHub OAuth when the GitHub sign-in disabled flag is enabled', async () => { - vi.stubEnv('NEXT_PUBLIC_AUTH_GITHUB_SIGN_IN_DISABLED', '1') - - await signInWithOAuthAction({ - provider: 'github', - returnTo: '/dashboard/team-123', - }) - - expect(encodedRedirect).toHaveBeenCalledWith( - 'error', - AUTH_URLS.SIGN_IN, - 'GitHub sign-in is temporarily paused while we migrate our authentication system. Please use another sign-in method.', - { returnTo: '/dashboard/team-123' } - ) - expect(fetchMock).not.toHaveBeenCalled() - expect(mockSupabaseClient.auth.signInWithOAuth).not.toHaveBeenCalled() - }) }) describe('Sign Out Flow', () => { From 4b047595497e12c97d280656894bd4742a8b3992 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 13:55:32 -0700 Subject: [PATCH 4/7] fix: address auth prep review feedback --- src/app/(auth)/sign-up/signup-form.tsx | 8 +--- src/app/dashboard/[teamSlug]/layout.tsx | 2 +- src/app/dashboard/[teamSlug]/team-gate.tsx | 8 +++- src/core/server/api/routers/user.ts | 37 ++++++++++++++----- .../dashboard/account/email-settings.tsx | 1 - tests/unit/user-router.test.ts | 21 +++++++++++ 6 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx index 588b34f59..fb21a1934 100644 --- a/src/app/(auth)/sign-up/signup-form.tsx +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -106,12 +106,8 @@ export default function SignUp() {

Sign up

- New sign-ups are temporarily paused while we migrate our - authentication system. Existing users can still{' '} - - sign in - - . + Sign-ups and sign-ins are temporarily paused while we migrate our + authentication system. Please try again later.

) diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 99251f84d..79768cc6e 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -61,7 +61,7 @@ export default async function DashboardLayout({ return ( - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index db1ab0dbf..8aede757d 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' +import type { AuthUser } from '@/core/server/auth' import { DashboardContextProvider } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' @@ -10,11 +11,13 @@ import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string + fallbackUser: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, + fallbackUser, children, }: DashboardTeamGateProps) { const trpc = useTRPC() @@ -34,9 +37,10 @@ export function DashboardTeamGate({ return } + const resolvedUser = user ?? fallbackUser const team = teams?.find((candidate) => candidate.slug === teamSlug) - if (!team || !teams || !user) { + if (!team || !teams) { return } @@ -44,7 +48,7 @@ export function DashboardTeamGate({ {children} diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts index a9e386a82..c0bc305a2 100644 --- a/src/core/server/api/routers/user.ts +++ b/src/core/server/api/routers/user.ts @@ -25,16 +25,23 @@ const UpdateUserSchema = z const TIMEOUT = Symbol('profile-lookup-timeout') -function withTimeout( +async function withTimeout( promise: Promise, ms: number ): Promise { - return Promise.race([ - promise, - new Promise((resolve) => { - setTimeout(() => resolve(TIMEOUT), ms) - }), - ]) + let timeout: ReturnType | undefined + + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(TIMEOUT), ms) + timeout.unref?.() + }), + ]) + } finally { + if (timeout) clearTimeout(timeout) + } } export const userRouter = createTRPCRouter({ @@ -86,11 +93,21 @@ export const userRouter = createTRPCRouter({ if (input.email !== undefined || input.password !== undefined) { const profile = await provider.getUserProfile() + const credentialProfile = profile ?? ctx.user + + if (!profile) { + l.warn( + { + key: 'trpc_user_update:profile_fallback', + user_id: ctx.user.id, + }, + 'user profile lookup failed during credential update; falling back to session user capabilities' + ) + } if ( - !profile || - (input.email !== undefined && !profile.canChangeEmail) || - (input.password !== undefined && !profile.canChangePassword) + (input.email !== undefined && !credentialProfile.canChangeEmail) || + (input.password !== undefined && !credentialProfile.canChangePassword) ) { return { status: 'error' as const, diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 34b6252ec..cac05af03 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -196,7 +196,6 @@ export function EmailSettings({ className }: EmailSettingsProps) { form.watch('email') === user.email } type="submit" - onClick={form.handleSubmit(submitEmailChange)} > Save diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts index 4b221f1fc..2fa488092 100644 --- a/tests/unit/user-router.test.ts +++ b/tests/unit/user-router.test.ts @@ -58,4 +58,25 @@ describe('userRouter.update', () => { expect(providerMock.getUserProfile).toHaveBeenCalled() expect(providerMock.updateUser).not.toHaveBeenCalled() }) + + it('falls back to session capabilities when live profile lookup fails', async () => { + providerMock.getUserProfile.mockResolvedValue(null) + providerMock.updateUser.mockResolvedValue({ + ok: true, + user: authUser, + }) + + const ctx = await createTRPCContext({ headers: new Headers() }) + const caller = createCaller(ctx) + + const result = await caller.update({ password: 'new-password' }) + + expect(result).toEqual({ status: 'ok', user: authUser }) + expect(providerMock.getUserProfile).toHaveBeenCalled() + expect(providerMock.updateUser).toHaveBeenCalledWith({ + email: undefined, + password: 'new-password', + name: undefined, + }) + }) }) From fef5e9010bda7b4c13b206bfaf60ac44dfe0227b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 13:58:41 -0700 Subject: [PATCH 5/7] fix: pause auth recovery and credential updates during migration --- src/core/server/actions/auth-actions.ts | 4 ++++ src/core/server/api/routers/user.ts | 19 ++++++++++++++++--- tests/integration/auth.test.ts | 16 ++++++++++++++++ tests/unit/user-router.test.ts | 22 +++++++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 7966599c1..0776855a7 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -355,6 +355,10 @@ export const forgotPasswordAction = actionClient .schema(forgotPasswordSchema) .metadata({ actionName: 'forgotPassword' }) .action(async ({ parsedInput: { email } }) => { + if (isAuthMigrationInProgress()) { + return returnServerError(AUTH_MIGRATION_SIGN_IN_DISABLED_MESSAGE) + } + const isHealthy = await checkAuthProviderHealth() if (!isHealthy) { throw encodedRedirect( diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts index c0bc305a2..8798e346e 100644 --- a/src/core/server/api/routers/user.ts +++ b/src/core/server/api/routers/user.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' +import { isAuthMigrationInProgress } from '@/configs/flags' import type { AuthUser } from '@/core/server/auth' import { createAuthForHeaders } from '@/core/server/auth' import { createTRPCRouter } from '@/core/server/trpc/init' @@ -92,14 +93,26 @@ export const userRouter = createTRPCRouter({ const provider = createAuthForHeaders(ctx.headers) if (input.email !== undefined || input.password !== undefined) { - const profile = await provider.getUserProfile() - const credentialProfile = profile ?? ctx.user + if (isAuthMigrationInProgress()) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + + const profile = await withTimeout( + provider.getUserProfile().catch(() => null), + PROFILE_LOOKUP_TIMEOUT_MS + ) + const credentialProfile = + profile && profile !== TIMEOUT ? profile : ctx.user - if (!profile) { + if (!profile || profile === TIMEOUT) { l.warn( { key: 'trpc_user_update:profile_fallback', user_id: ctx.user.id, + context: { timed_out: profile === TIMEOUT }, }, 'user profile lookup failed during credential update; falling back to session user capabilities' ) diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 263c70d2b..0812ecb06 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -363,6 +363,22 @@ describe('Auth Actions - Integration Tests', () => { expect(result).not.toHaveProperty('validationErrors') }) + it('should block forgot password while auth migration is in progress', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS', '1') + + const result = await forgotPasswordAction({ + email: 'user@example.com', + }) + + expect(result?.serverError).toBe( + 'Sign-ins are temporarily paused while we migrate our authentication system. Please try again later.' + ) + expect(fetchMock).not.toHaveBeenCalled() + expect( + mockSupabaseClient.auth.resetPasswordForEmail + ).not.toHaveBeenCalled() + }) + /** * VALIDATION TEST: Verifies that forgot password with missing email * shows appropriate error message diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts index 2fa488092..1fbcb997e 100644 --- a/tests/unit/user-router.test.ts +++ b/tests/unit/user-router.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTRPCContext } from '@/core/server/trpc/init' const providerMock = vi.hoisted(() => ({ @@ -43,6 +43,10 @@ describe('userRouter.update', () => { providerMock.updateUser.mockReset() }) + afterEach(() => { + vi.unstubAllEnvs() + }) + it('denies email changes when the provider profile says they are not changeable', async () => { providerMock.getUserProfile.mockResolvedValue(authUser) @@ -79,4 +83,20 @@ describe('userRouter.update', () => { name: undefined, }) }) + + it('blocks credential updates while auth migration is in progress', async () => { + vi.stubEnv('NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS', '1') + + const ctx = await createTRPCContext({ headers: new Headers() }) + const caller = createCaller(ctx) + + const result = await caller.update({ password: 'new-password' }) + + expect(result).toEqual({ + status: 'error', + code: 'account_credentials_not_changeable', + }) + expect(providerMock.getUserProfile).not.toHaveBeenCalled() + expect(providerMock.updateUser).not.toHaveBeenCalled() + }) }) From 1f38f912641329029fa7bb4c5a12754e7dd8cc7a Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 14:08:11 -0700 Subject: [PATCH 6/7] fix: handle email change otp redirects --- src/app/api/auth/verify-otp/route.ts | 7 +++ .../dashboard/account/email-settings.tsx | 9 ++- tests/integration/verify-otp-route.test.ts | 57 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 tests/integration/verify-otp-route.test.ts diff --git a/src/app/api/auth/verify-otp/route.ts b/src/app/api/auth/verify-otp/route.ts index 2e478416e..619c674b5 100644 --- a/src/app/api/auth/verify-otp/route.ts +++ b/src/app/api/auth/verify-otp/route.ts @@ -25,6 +25,13 @@ function buildRedirectUrl( redirectUrl.searchParams.set(key, value) }) + if (type === 'email_change') { + redirectUrl.pathname = PROTECTED_URLS.ACCOUNT_SETTINGS + redirectUrl.searchParams.set('success', 'E-Mail changed successfully') + redirectUrl.searchParams.set('type', 'update_email') + return redirectUrl.toString() + } + if (type === 'recovery') { redirectUrl.pathname = PROTECTED_URLS.RESET_PASSWORD redirectUrl.searchParams.set('reauth', '1') diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index cac05af03..f0cfb276e 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -132,11 +132,10 @@ export function EmailSettings({ className }: EmailSettingsProps) { return } - toast( - defaultErrorToast( - searchParams.get('error') ?? 'Failed to update e-mail.' - ) - ) + const error = searchParams.get('error') + if (error !== null) { + toast(defaultErrorToast(error)) + } } }, [searchParams, toast]) diff --git a/tests/integration/verify-otp-route.test.ts b/tests/integration/verify-otp-route.test.ts new file mode 100644 index 000000000..c1110100c --- /dev/null +++ b/tests/integration/verify-otp-route.test.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { POST } from '@/app/api/auth/verify-otp/route' +import { PROTECTED_URLS } from '@/configs/urls' + +const { mockVerifyOtp } = vi.hoisted(() => ({ + mockVerifyOtp: vi.fn(), +})) + +vi.mock('@/core/modules/auth/repository.server', () => ({ + authRepository: { + verifyOtp: mockVerifyOtp, + }, +})) + +describe('Verify OTP Route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('redirects successful email changes to account settings without reusing the email callback', async () => { + mockVerifyOtp.mockResolvedValue({ + ok: true, + data: { userId: 'user-123' }, + }) + + const response = await POST( + createRequest({ + token_hash: 'token-hash', + type: 'email_change', + next: 'http://localhost:3000/api/auth/email-callback?new_email=new%40example.com', + }) + ) + + const body = await response.json() + const redirectUrl = new URL(body.redirectUrl) + + expect(mockVerifyOtp).toHaveBeenCalledWith('token-hash', 'email_change') + expect(redirectUrl.pathname).toBe(PROTECTED_URLS.ACCOUNT_SETTINGS) + expect(redirectUrl.searchParams.get('success')).toBe( + 'E-Mail changed successfully' + ) + expect(redirectUrl.searchParams.get('type')).toBe('update_email') + expect(redirectUrl.searchParams.get('new_email')).toBe('new@example.com') + expect(redirectUrl.searchParams.has('reauth')).toBe(false) + }) +}) + +function createRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:3000/api/auth/verify-otp', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }) +} From c3bd266d5aea6bd55175baf343f5269f3677a843 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 5 Jun 2026 14:21:33 -0700 Subject: [PATCH 7/7] fix: silence expected missing auth sessions --- src/core/server/auth/supabase/provider.ts | 28 ++++++++++++++++------- tests/unit/auth-supabase-provider.test.ts | 20 ++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts index a506eebd9..d34c4d371 100644 --- a/src/core/server/auth/supabase/provider.ts +++ b/src/core/server/auth/supabase/provider.ts @@ -31,13 +31,15 @@ export class SupabaseAuthProvider implements AuthProvider { const { data, error: userError } = await client.auth.getUser() if (userError) { - l.error( - { - key: 'auth_provider:get_user:error', - error: serializeErrorForLog(userError), - }, - `supabase getUser failed: ${userError.message}` - ) + if (!isAuthSessionMissingError(userError)) { + l.error( + { + key: 'auth_provider:get_user:error', + error: serializeErrorForLog(userError), + }, + `supabase getUser failed: ${userError.message}` + ) + } return null } @@ -75,7 +77,7 @@ export class SupabaseAuthProvider implements AuthProvider { const { data, error } = await client.auth.getUser() if (error || !data.user) { - if (error) { + if (error && !isAuthSessionMissingError(error)) { l.error( { key: 'auth_provider:get_user_profile:error', @@ -219,3 +221,13 @@ function buildSignInRedirect(returnTo?: string): string { const params = new URLSearchParams({ returnTo }) return `${AUTH_URLS.SIGN_IN}?${params.toString()}` } + +function isAuthSessionMissingError(error: { + message?: string + name?: string +}): boolean { + return ( + error.name === 'AuthSessionMissingError' || + error.message === 'Auth session missing!' + ) +} diff --git a/tests/unit/auth-supabase-provider.test.ts b/tests/unit/auth-supabase-provider.test.ts index 468772127..791e833f5 100644 --- a/tests/unit/auth-supabase-provider.test.ts +++ b/tests/unit/auth-supabase-provider.test.ts @@ -87,6 +87,26 @@ describe('SupabaseAuthProvider', () => { expect(loggerMocks.error).not.toHaveBeenCalled() }) + it('returns null without logging when no auth session exists', async () => { + const client = buildClient({ + getUser: vi.fn().mockResolvedValue({ + data: { user: null }, + error: { + name: 'AuthSessionMissingError', + message: 'Auth session missing!', + status: 400, + }, + }), + }) + const provider = new SupabaseAuthProvider(client) + + const result = await provider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.error).not.toHaveBeenCalled() + expect(loggerMocks.warn).not.toHaveBeenCalled() + }) + it('logs and returns null when getSession returns an error', async () => { const sessionError = { message: 'session lookup failed', status: 500 } const client = buildClient({