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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/app/api/auth/recovery-complete/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 7 additions & 1 deletion src/app/api/auth/sign-out/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
6 changes: 5 additions & 1 deletion src/app/login/components/custom-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Button {...buttonProps} loading={isSubmitting ? loadingLabel : undefined}>
Expand Down
2 changes: 2 additions & 0 deletions src/app/login/components/custom-card-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const TITLE_BY_FLOW: Partial<Record<FlowType, string>> = {
[FlowType.Registration]: 'Sign up',
[FlowType.Recovery]: 'Reset your password',
[FlowType.Verification]: 'Verify your email',
[FlowType.Settings]: 'Set a new password',
}

const REAUTH_DESCRIPTION: Record<ReauthCredential, string> = {
Expand All @@ -22,6 +23,7 @@ const DESCRIPTION_BY_FLOW: Partial<Record<FlowType, string>> = {
[FlowType.Registration]: 'Sign up with a social provider or your email.',
[FlowType.Recovery]: 'Enter your email to recover your account.',
[FlowType.Verification]: 'Enter your email to verify your account.',
[FlowType.Settings]: 'Choose a new password for your account.',
}

export function OryCardHeader() {
Expand Down
38 changes: 36 additions & 2 deletions src/app/login/components/custom-input.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,49 @@
'use client'

import type { OryNodeInputProps } from '@ory/elements-react'
import { useState } from 'react'
import { EyeClosedIcon, EyeOpenIcon } from '@/ui/primitives/icons'
import { Input } from '@/ui/primitives/input'

export function OryInput({ inputProps, node }: OryNodeInputProps) {
const isPassword = inputProps.type === 'password'
const [revealed, setRevealed] = useState(false)

const placeholder =
node.attributes.name === 'identifier' || inputProps.type === 'email'
? 'you@example.com'
: inputProps.type === 'password'
: isPassword
? '••••••••••••'
: undefined

return <Input {...inputProps} {...(placeholder ? { placeholder } : {})} />
const input = (
<Input
{...inputProps}
{...(placeholder ? { placeholder } : {})}
{...(isPassword
? { type: revealed ? 'text' : 'password', className: 'pr-8' }
: {})}
/>
)

if (!isPassword) return input

return (
<div className="relative w-full">
{input}
<button
type="button"
aria-label={revealed ? 'Hide password' : 'Show password'}
aria-pressed={revealed}
onClick={() => setRevealed((value) => !value)}
className="text-fg-tertiary hover:text-fg absolute top-1/2 right-2 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center"
>
{revealed ? (
<EyeClosedIcon className="size-4" />
) : (
<EyeOpenIcon className="size-4" />
)}
</button>
</div>
)
}
15 changes: 13 additions & 2 deletions src/app/login/components/custom-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
drankou marked this conversation as resolved.
const label = node.meta?.label?.text
const messages = node.messages ?? []
const fieldErrorText =
Expand Down Expand Up @@ -37,7 +48,7 @@ export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) {
<Link
prefetch={false}
target="_top"
href={AUTH_URLS.FORGOT_PASSWORD}
href={recoverHref}
tabIndex={-1}
className="prose-label text-fg-secondary hover:text-fg underline underline-offset-[3px]"
>
Expand Down
35 changes: 35 additions & 0 deletions src/app/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative flex min-h-svh flex-col">
<header className="bg-bg/40 sticky top-0 z-50 flex items-center justify-between gap-3 border-b px-4 py-4 backdrop-blur-md md:px-6">
<h1 className="truncate">Account</h1>
<a
href={`${AUTH_URLS.SIGN_OUT}?return_to=${encodeURIComponent(AUTH_URLS.SIGN_IN)}`}
className={`${buttonVariants({ variant: 'secondary' })} shrink-0`}
>
Sign out
</a>
</header>
<div className="flex w-full flex-1 justify-center px-4 py-8">
<div className="w-full max-w-2xl">{children}</div>
</div>
</div>
)
}
35 changes: 35 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsCards
flow={flow}
config={config}
name={user?.name ?? null}
email={user?.email ?? null}
/>
)
}
Loading
Loading