diff --git a/src/app/api/auth/recovery-complete/route.ts b/src/app/api/auth/recovery-complete/route.ts new file mode 100644 index 000000000..b4b80ba54 --- /dev/null +++ b/src/app/api/auth/recovery-complete/route.ts @@ -0,0 +1,22 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS } from '@/configs/urls' +import { handleCredentialChangeSuccess } from '@/core/server/auth' +import { clearAppSessionCookies } from '@/core/server/auth/ory/clear-session-cookies' + +// Post-recovery password reset on /settings completed. The Kratos session minted +// by the recovery flow is still live, so a bare redirect to /sign-in lets Hydra +// silently mint tokens off it without a password prompt. Revoke every session +// for the identity (this device plus any other live session, e.g. a takeover's +// session elsewhere) and clear cookies before sending the user to /sign-in to +// authenticate with the new password. +export async function GET(request: NextRequest) { + await handleCredentialChangeSuccess() + + const response = NextResponse.redirect( + new URL(AUTH_URLS.SIGN_IN, request.nextUrl.origin) + ) + clearAppSessionCookies(request, response) + return response +} diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index fe1046134..5a0f11e62 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -9,7 +9,13 @@ import { clearAppSessionCookies } from '@/core/server/auth/ory/clear-session-coo // tokens and the Kratos identity session server-side, and the redirect ends // Hydra's OAuth2 session; we drop the browser cookies here. export async function GET(request: NextRequest) { - const { redirectTo } = await signOut({ origin: request.nextUrl.origin }) + // `return_to` steers the post-logout landing for sessions without a Hydra + // id_token (e.g. signing out of /settings mid-recovery → back to sign-in). + const returnTo = request.nextUrl.searchParams.get('return_to') ?? undefined + const { redirectTo } = await signOut({ + origin: request.nextUrl.origin, + returnTo, + }) const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin) ) diff --git a/src/app/login/components/custom-button.tsx b/src/app/login/components/custom-button.tsx index 40d56d5aa..a1085472f 100644 --- a/src/app/login/components/custom-button.tsx +++ b/src/app/login/components/custom-button.tsx @@ -13,7 +13,11 @@ export function OryButton({ const { flowType } = useOryFlow() const label = node.meta?.label?.text const loadingLabel = - flowType === FlowType.Registration ? 'Signing up…' : 'Signing in…' + flowType === FlowType.Registration + ? 'Signing up…' + : flowType === FlowType.Settings + ? 'Saving…' + : 'Signing in…' return ( + + ) } diff --git a/src/app/login/components/custom-label.tsx b/src/app/login/components/custom-label.tsx index 59b07f7d4..e9187d674 100644 --- a/src/app/login/components/custom-label.tsx +++ b/src/app/login/components/custom-label.tsx @@ -6,9 +6,20 @@ import Link from 'next/link' import { AUTH_URLS } from '@/configs/urls' import { cn } from '@/lib/utils' import { Label } from '@/ui/primitives/label' +import { getReauthInfo } from './reauth' export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { - const { flowType } = useOryFlow() + const oryFlow = useOryFlow() + const { flowType } = oryFlow + + // On a reauth screen the user already holds a Kratos session, so starting + // recovery directly bounces to the default return URL ("already logged in"). + // Route through sign-out first to clear the session, then land on recovery. + const { isReauthLogin } = getReauthInfo(oryFlow) + const recoverHref = isReauthLogin + ? `${AUTH_URLS.SIGN_OUT}?return_to=${encodeURIComponent(AUTH_URLS.FORGOT_PASSWORD)}` + : AUTH_URLS.FORGOT_PASSWORD + const label = node.meta?.label?.text const messages = node.messages ?? [] const fieldErrorText = @@ -37,7 +48,7 @@ export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx new file mode 100644 index 000000000..2c2cf6e73 --- /dev/null +++ b/src/app/settings/layout.tsx @@ -0,0 +1,35 @@ +import { ALLOW_SEO_INDEXING } from '@/configs/env-flags' +import { METADATA } from '@/configs/metadata' +import { AUTH_URLS } from '@/configs/urls' +import { buttonVariants } from '@/ui/primitives/button' + +export const metadata = { + title: METADATA.title, + description: METADATA.description, + robots: ALLOW_SEO_INDEXING ? 'index, follow' : 'noindex, nofollow', +} + +// Shell-less: no sidebar/team chrome, so the page renders with only a Kratos +// session (the post-recovery password reset has no e2b_session yet). +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+
+

Account

+ + Sign out + +
+
+
{children}
+
+
+ ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 000000000..59e8bcbb0 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,35 @@ +import { getSettingsFlow, type OryPageParams } from '@ory/nextjs/app' +import oryConfig from '@/configs/ory' +import { getUserProfile } from '@/core/server/auth' +import { getOryConfigForRequest } from '@/core/server/auth/ory/request-config' +import { SettingsCards } from './settings-cards' + +export const dynamic = 'force-dynamic' + +// Ory-driven settings page, intentionally separate from /dashboard/account. +// It needs only a Kratos session (getSettingsFlow + getUserProfile read the +// session/identity, not e2b_session) — so the post-recovery password reset works +// before any Hydra token exists. Name/e-mail are shown read-only for reference; +// editing the account profile stays on the gated /dashboard/account page. +export default async function SettingsPage(props: OryPageParams) { + const flow = await getSettingsFlow(oryConfig, props.searchParams) + + // getSettingsFlow has already redirected (created a flow / surfaced login). + if (!flow) { + return null + } + + const [config, user] = await Promise.all([ + getOryConfigForRequest(), + getUserProfile(), + ]) + + return ( + + ) +} diff --git a/src/app/settings/settings-cards.tsx b/src/app/settings/settings-cards.tsx new file mode 100644 index 000000000..996ecf8ee --- /dev/null +++ b/src/app/settings/settings-cards.tsx @@ -0,0 +1,232 @@ +'use client' + +import { + Node, + type OryCardSettingsSectionProps, + OryCardValidationMessages, + type OryFlowComponentOverrides, + OrySettingsFormSection, + useOryFlow, +} from '@ory/elements-react' +import { SessionProvider } from '@ory/elements-react/client' +import { Settings } from '@ory/elements-react/theme' +import { type ComponentProps, useState } from 'react' +import { oryComponents } from '@/app/login/components' +import { AUTH_URLS } from '@/configs/urls' +import { Button } from '@/ui/primitives/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/ui/primitives/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { Input } from '@/ui/primitives/input' + +type SettingsProps = ComponentProps + +interface SettingsCardsProps extends Pick { + // Read-only Kratos identity traits, shown for reference during the reset. + name: string | null + email: string | null +} + +// 's default body (OryPageHeader + OrySettingsCard) and the form +// wrapper (Card.SettingsSection) are unstyled without the Ory theme stylesheet, +// which the dashboard never loads. We render our own dashboard card via the +// `children` slot and only override the form wrapper Ory drives submission with; +// inputs/buttons/messages already use the dashboard-themed oryComponents. +function SettingsSectionForm({ + children, + action, + method, + onSubmit, +}: OryCardSettingsSectionProps) { + return ( +
+ {children} +
+ ) +} + +const settingsComponents: OryFlowComponentOverrides = { + ...oryComponents, + Card: { + ...oryComponents.Card, + SettingsSection: SettingsSectionForm, + }, +} + +type SettingsNode = ReturnType['flow']['ui']['nodes'][number] + +// The submit ("Save") node; the cast sidesteps the union typing of node +// attributes (and the dual @ory/client-fetch copies this repo installs). +function isSubmitNode(node: SettingsNode): boolean { + return ( + 'type' in node.attributes && + (node.attributes as { type?: string }).type === 'submit' + ) +} + +// Read-only reference field (name / e-mail). Shown for context during the +// password reset — account edits live on the gated /dashboard/account page. +function ReadOnlyField({ + title, + description, + value, +}: { + title: string + description: string + value: string | null +}) { + return ( + + + {title} + {description} + + + + + + ) +} + +// The Ory password method, rendered as a dashboard card via the settings flow. +function PasswordCard() { + const { flow } = useOryFlow() + // `default` carries the CSRF hidden input the submission needs. + const nodes = flow.ui.nodes.filter( + (node) => node.group === 'password' || node.group === 'default' + ) + const submitNodes = nodes.filter(isSubmitNode) + const fieldNodes = nodes.filter((node) => !isSubmitNode(node)) + + return ( + + + Password + Set a new password for your account. + + + + + + {fieldNodes.map((node, index) => ( + + ))} + + +

+ Your password must be at least 8 characters long. +

+ {submitNodes.map((node, index) => ( + + ))} +
+
+
+ ) +} + +function PasswordChangedDialog({ open }: { open: boolean }) { + return ( + // Controlled `open` with no onOpenChange + hideClose + blocked outside/esc: + // the dialog can't be dismissed, so the only way forward is sign-in. + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + Password updated + + Your password has been changed. Sign in with your new password to + continue. + + + + + + + + ) +} + +export function SettingsCards({ + flow, + config, + name, + email, +}: SettingsCardsProps) { + const [passwordChanged, setPasswordChanged] = useState(false) + + // Password is the only submittable method here, so any success is the password + // change. Take over from Ory: open our dialog and return a never-resolving + // promise. Per OrySuccessHandler, that suspends Ory's default post-success + // behavior — specifically the `continue_with` redirect (window.location.assign) + // that otherwise reloads the page to settings_ui_url. No reload; the user + // proceeds via the dialog. + const handleSuccess = () => { + setPasswordChanged(true) + return new Promise(() => {}) + } + + return ( +
+ + + + + + + + + {/* The recovery Kratos session is still live, so "sign in" routes through + RECOVERY_COMPLETE, which revokes every session for the identity (this + device plus any other live session) and clears cookies before + /sign-in — forcing a real password entry. The dialog can't be + dismissed: the only way forward is to sign in. */} + +
+ ) +} diff --git a/src/configs/ory.ts b/src/configs/ory.ts index 964ad5006..8edeff341 100644 --- a/src/configs/ory.ts +++ b/src/configs/ory.ts @@ -1,5 +1,5 @@ import type { OryClientConfiguration } from '@ory/elements-react' -import { PROTECTED_URLS } from '@/configs/urls' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' // `sdk.url` must be the app's own origin so the Elements client's // /self-service/* calls stay same-origin (Kratos' wildcard CORS rejects @@ -21,7 +21,7 @@ const oryConfig: OryClientConfiguration = { recovery_ui_url: '/recovery', verification_enabled: true, verification_ui_url: '/verification', - settings_ui_url: PROTECTED_URLS.ACCOUNT_SETTINGS, + settings_ui_url: AUTH_URLS.SETTINGS, }, } diff --git a/src/configs/urls.ts b/src/configs/urls.ts index d0f85c608..d650c0467 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -2,8 +2,14 @@ export const AUTH_URLS = { FORGOT_PASSWORD: '/recovery', SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', + SIGN_OUT: '/api/auth/sign-out', SWITCH_ACCOUNT: '/api/auth/switch-account', + RECOVERY_COMPLETE: '/api/auth/recovery-complete', CLI: '/auth/cli', + // Shell-less Ory settings page (password reset + account config). Reachable + // with only a Kratos session, so the post-recovery password reset works + // before any e2b_session (Hydra token) exists. Kratos' settings_ui_url. + SETTINGS: '/settings', } export const PROTECTED_URLS = { diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 476f49a06..3d33cd1fb 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -113,7 +113,7 @@ export async function signOut( await revokeCurrentSession() return { - redirectTo: await completeOrySignOut(options?.origin), + redirectTo: await completeOrySignOut(options?.origin, options?.returnTo), } } diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index 35a367779..5e9e29afd 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -3,15 +3,32 @@ import 'server-only' import { cookies } from 'next/headers' import { BASE_URL } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { normalizeOryReturnTo } from './build-start-url' import { E2B_SESSION_COOKIE, openSessionCookie } from './session-cookie' import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout' -// RP-initiated logout: hand Hydra the id_token so it ends its own OAuth2 session -// and (since it delegates login to Kratos) the Kratos session, then returns to -// post_logout_redirect_uri. The sign-out route clears e2b_session on the -// redirect it emits. Falls back to home if there's no id_token to present. -export async function completeOrySignOut(origin = BASE_URL): Promise { - const fallback = new URL(ORY_POST_LOGOUT_PATH, origin).toString() +// Resolves the post-logout landing for the sign-out route. +// +// An explicit internal `returnTo` (reauth "Recover Account" → /recovery, +// /settings sign-out → /sign-in) names where to go next. Hydra's RP-logout +// can't honor it — post_logout_redirect_uri must be a pre-registered URI, so it +// always lands on ORY_POST_LOGOUT_PATH and the path is dropped. signOut() has +// already revoked the tokens + Kratos session (production accepts login with +// remember=false, so Hydra holds no session of its own to end) and the route +// clears the cookies, so we skip the Hydra round-trip and 302 straight to the +// requested path. +// +// The default full sign-out passes no `returnTo` and falls through to Hydra's +// RP-initiated logout: the id_token lets Hydra end its OAuth2 session and the +// delegated Kratos session, then return to post_logout_redirect_uri. +export async function completeOrySignOut( + origin = BASE_URL, + returnTo?: string +): Promise { + const target = normalizeOryReturnTo(returnTo) + if (target) return new URL(target, origin).toString() + + const home = new URL(ORY_POST_LOGOUT_PATH, origin).toString() let idToken: string | undefined try { @@ -30,8 +47,8 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { ) } - if (!idToken) return fallback + if (!idToken) return home const logoutUrl = await buildOryLogoutUrl({ idToken, origin }) - return logoutUrl?.toString() ?? fallback + return logoutUrl?.toString() ?? home }