diff --git a/packages/frontend/e2e/helpers/login.ts b/packages/frontend/e2e/helpers/login.ts index d0119248..f14609f7 100644 --- a/packages/frontend/e2e/helpers/login.ts +++ b/packages/frontend/e2e/helpers/login.ts @@ -93,8 +93,35 @@ export const emailVerifyPage = { } as const; /** - * Performs a complete login flow: navigates from the method - * selection page through the password form and submits credentials. + * Moves from the current login entry point to the password form. + * Password-only configs redirect from /login directly to /login/password, + * while multi-method configs still require selecting the password method. + */ +export async function openPasswordLoginFromCurrentPage( + page: Page, +): Promise { + const passwordForm = page.locator(loginPasswordPage.emailInput); + const passwordMethodLink = page.locator(loginMethodPage.passwordMethodLink); + + for (let attempt = 0; attempt < 50; attempt += 1) { + if (await passwordForm.isVisible()) { + return; + } + + if (await passwordMethodLink.isVisible()) { + await passwordMethodLink.click(); + await page.waitForURL('**/login/password**'); + return; + } + + await page.waitForTimeout(100); + } + + await passwordForm.waitFor({ state: 'visible', timeout: 1_000 }); +} + +/** + * Performs a complete login flow through the password form. */ export async function performLogin( page: Page, @@ -102,8 +129,7 @@ export async function performLogin( password: string, ): Promise { await gotoLogin(page); - await page.locator(loginMethodPage.passwordMethodLink).click(); - await page.waitForURL('**/login/password'); + await openPasswordLoginFromCurrentPage(page); await page.locator(loginPasswordPage.emailInput).fill(email); await page.locator(loginPasswordPage.passwordInput).fill(password); await page.locator(loginPasswordPage.submitButton).click(); diff --git a/packages/frontend/e2e/tests/account-deletion/delete-account.test.ts b/packages/frontend/e2e/tests/account-deletion/delete-account.test.ts index 40a9d93c..facdd987 100644 --- a/packages/frontend/e2e/tests/account-deletion/delete-account.test.ts +++ b/packages/frontend/e2e/tests/account-deletion/delete-account.test.ts @@ -83,7 +83,7 @@ test.describe('Delete account flow', () => { await page.locator(deleteAccountModal.submitButton).click(); // Should redirect to login - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); @@ -158,7 +158,7 @@ test.describe('Delete account flow', () => { await page.locator(deleteAccountModal.submitButton).click(); // Wait for redirect to login - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); // Try to login with the deleted account await gotoWithFirefoxRetry(page, browserName, '/login/password'); diff --git a/packages/frontend/e2e/tests/email-verification/forgot-password.test.ts b/packages/frontend/e2e/tests/email-verification/forgot-password.test.ts index b49638db..41b7844a 100644 --- a/packages/frontend/e2e/tests/email-verification/forgot-password.test.ts +++ b/packages/frontend/e2e/tests/email-verification/forgot-password.test.ts @@ -80,7 +80,7 @@ test.describe('Forgot password flow', () => { await expect(loginLink).toBeVisible(); await loginLink.click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/packages/frontend/e2e/tests/email-verification/reset-password.test.ts b/packages/frontend/e2e/tests/email-verification/reset-password.test.ts index 05a99ea5..3105e24f 100644 --- a/packages/frontend/e2e/tests/email-verification/reset-password.test.ts +++ b/packages/frontend/e2e/tests/email-verification/reset-password.test.ts @@ -251,7 +251,7 @@ test.describe('Reset password flow', () => { await page.getByRole('button', { name: 'Go to login' }).click(); // Should navigate to login - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/packages/frontend/e2e/tests/journey-oauth-2fa/journey-oauth-2fa.test.ts b/packages/frontend/e2e/tests/journey-oauth-2fa/journey-oauth-2fa.test.ts index 34dd18fc..5f5bf263 100644 --- a/packages/frontend/e2e/tests/journey-oauth-2fa/journey-oauth-2fa.test.ts +++ b/packages/frontend/e2e/tests/journey-oauth-2fa/journey-oauth-2fa.test.ts @@ -266,7 +266,7 @@ test.describe('OAuth continuation across email verification and 2FA', () => { hasVirtualAuth = false; await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await page.goto(buildAuthEntryUrl('login', 'password', oauthParams)); await submitPasswordLogin(page, email, TEST_PASSWORD); diff --git a/packages/frontend/e2e/tests/minimal/account-selection.test.ts b/packages/frontend/e2e/tests/minimal/account-selection.test.ts index b87ff9e0..7d09a810 100644 --- a/packages/frontend/e2e/tests/minimal/account-selection.test.ts +++ b/packages/frontend/e2e/tests/minimal/account-selection.test.ts @@ -10,6 +10,7 @@ import { E2E_TEST_CLIENT_CONFIG, } from '#frontend-e2e/fixtures/index.ts'; import { consentPage } from '#frontend-e2e/helpers/consent.ts'; +import { openPasswordLoginFromCurrentPage } from '#frontend-e2e/helpers/login.ts'; import { buildAuthorizePath, buildOAuthFlowInput, @@ -272,8 +273,7 @@ test.describe('OIDC account selection', () => { await page.getByRole('link', { name: /Use another account/i }).click(); await expect(page).toHaveURL(/\/login/); - await page.locator('a[href^="/login/password"]').click(); - await expect(page).toHaveURL(/\/login\/password/); + await openPasswordLoginFromCurrentPage(page); await page .locator('input[name="email"]') .fill('account-selection-carol@example.com'); @@ -460,8 +460,7 @@ test.describe('OIDC account selection', () => { buildAuthorizePath(flow.authorizeParams), ); await expect(page).toHaveURL(/\/login/); - await page.locator('a[href^="/login/password"]').click(); - await expect(page).toHaveURL(/\/login\/password/); + await openPasswordLoginFromCurrentPage(page); const callbackRoute = `${E2E_TEST_CLIENT.redirectUri}**`; const callbackRouteHandler = async ( @@ -531,8 +530,7 @@ test.describe('OIDC account selection', () => { await expect(page).toHaveURL(/\/login/); expect(page.url()).not.toContain('/account/select'); - await page.locator('a[href^="/login/password"]').click(); - await expect(page).toHaveURL(/\/login\/password/); + await openPasswordLoginFromCurrentPage(page); const callbackRoute = `${E2E_TEST_CLIENT.redirectUri}**`; const callbackRouteHandler = async ( diff --git a/packages/frontend/e2e/tests/minimal/error-page.test.ts b/packages/frontend/e2e/tests/minimal/error-page.test.ts index a1e59766..d9ae0f48 100644 --- a/packages/frontend/e2e/tests/minimal/error-page.test.ts +++ b/packages/frontend/e2e/tests/minimal/error-page.test.ts @@ -40,7 +40,7 @@ test.describe('Error page', () => { // Click "Go to login" link await page.getByRole('link', { name: 'Go to login' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/packages/frontend/e2e/tests/minimal/login.test.ts b/packages/frontend/e2e/tests/minimal/login.test.ts index 17289474..06c6b3c1 100644 --- a/packages/frontend/e2e/tests/minimal/login.test.ts +++ b/packages/frontend/e2e/tests/minimal/login.test.ts @@ -7,7 +7,6 @@ import { E2E_TEST_USER_CONFIG, } from '#frontend-e2e/fixtures/index.ts'; import { - loginMethodPage, loginPasswordPage, performLogin, } from '#frontend-e2e/helpers/login.ts'; @@ -21,23 +20,22 @@ const test = createScenarioFixture((backendPort) => ({ test.describe('Login flow', () => { test('redirects unauthenticated users to /login', async ({ page }) => { await page.goto('/'); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); - test('method selection page shows password login link', async ({ page }) => { + test('password-only login opens the password form directly', async ({ + page, + }) => { await page.goto('/login'); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await page.waitForURL('**/login/password**'); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); }); - test('clicking password method navigates to password form', async ({ + test('password login form is available at the direct URL', async ({ page, }) => { - await page.goto('/login'); - await page.locator(loginMethodPage.passwordMethodLink).click(); - await page.waitForURL('**/login/password'); + await page.goto('/login/password'); await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); await expect(page.locator(loginPasswordPage.passwordInput)).toBeVisible(); @@ -110,9 +108,7 @@ test.describe('Login flow', () => { await expect(page).toHaveURL(/\/login\/password/); }); - test('full flow: method selection through login to profile', async ({ - page, - }) => { + test('full password login flow navigates to profile', async ({ page }) => { await performLogin(page, E2E_TEST_USER.email, E2E_TEST_USER.password); await page.waitForURL('**/profile'); await expect(page).toHaveURL(/\/profile/); diff --git a/packages/frontend/e2e/tests/minimal/oauth-client-auth-flow.test.ts b/packages/frontend/e2e/tests/minimal/oauth-client-auth-flow.test.ts index e3c5ac2a..1e2fbbf0 100644 --- a/packages/frontend/e2e/tests/minimal/oauth-client-auth-flow.test.ts +++ b/packages/frontend/e2e/tests/minimal/oauth-client-auth-flow.test.ts @@ -8,8 +8,8 @@ import { import { consentPage } from '#frontend-e2e/helpers/consent.ts'; import { uniqueEmail as createUniqueEmail } from '#frontend-e2e/helpers/identity.ts'; import { - loginMethodPage, loginPasswordPage, + openPasswordLoginFromCurrentPage, } from '#frontend-e2e/helpers/login.ts'; import { allowConsentAndCaptureCode, @@ -61,8 +61,7 @@ async function loginThroughPasswordForm( email: string, password: string, ): Promise { - await page.locator(loginMethodPage.passwordMethodLink).click(); - await page.waitForURL('**/login/password**'); + await openPasswordLoginFromCurrentPage(page); await page.locator(loginPasswordPage.emailInput).fill(email); await page.locator(loginPasswordPage.passwordInput).fill(password); await page.locator(loginPasswordPage.submitButton).click(); @@ -78,9 +77,8 @@ test.describe('OAuth client authentication flow', () => { await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); + await expect(page.getByTestId('authorization-context')).toBeVisible(); expectOAuthParamsInCurrentUrl(page, oauth.authorizeParams); }); @@ -96,9 +94,7 @@ test.describe('OAuth client authentication flow', () => { await page.goto(buildAuthorizePath(oauth.authorizeParams)); await page.waitForURL('**/login**'); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); expectOAuthParamsInCurrentUrl(page, oauth.authorizeParams); await loginThroughPasswordForm(page, email, TEST_PASSWORD); @@ -128,9 +124,7 @@ test.describe('OAuth client authentication flow', () => { await page.goto(buildAuthorizePath(oauth.authorizeParams)); await page.waitForURL('**/login**'); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); expectOAuthParamsInCurrentUrl(page, oauth.authorizeParams); await page.goto( @@ -172,9 +166,7 @@ test.describe('OAuth client authentication flow', () => { `${String(baseURL)}${buildAuthorizePath(initialFlow.authorizeParams)}`, ); await firstPage.waitForURL('**/login**'); - await expect( - firstPage.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(firstPage.locator(loginPasswordPage.emailInput)).toBeVisible(); await loginThroughPasswordForm(firstPage, email, TEST_PASSWORD); await firstPage.waitForURL('**/consent**'); @@ -196,11 +188,10 @@ test.describe('OAuth client authentication flow', () => { ); await secondPage.waitForURL('**/login**'); await expect( - secondPage.locator(loginMethodPage.passwordMethodLink), + secondPage.locator(loginPasswordPage.emailInput), ).toBeVisible(); - await secondPage.locator(loginMethodPage.passwordMethodLink).click(); - await secondPage.waitForURL('**/login/password**'); + await openPasswordLoginFromCurrentPage(secondPage); await secondPage.locator(loginPasswordPage.emailInput).fill(email); await secondPage .locator(loginPasswordPage.passwordInput) @@ -245,9 +236,7 @@ test.describe('OAuth client authentication flow', () => { await page.goto(buildAuthorizePath(oauth.authorizeParams)); await page.waitForURL('**/login**'); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); await loginThroughPasswordForm(page, email, TEST_PASSWORD); await page.waitForURL('**/consent**'); @@ -268,9 +257,7 @@ test.describe('OAuth client authentication flow', () => { await page.goto(buildAuthorizePath(oauth.authorizeParams)); await page.waitForURL('**/login**'); - await expect( - page.locator(loginMethodPage.passwordMethodLink), - ).toBeVisible(); + await expect(page.locator(loginPasswordPage.emailInput)).toBeVisible(); await loginThroughPasswordForm(page, email, TEST_PASSWORD); await page.waitForURL('**/consent**'); diff --git a/packages/frontend/e2e/tests/minimal/profile.test.ts b/packages/frontend/e2e/tests/minimal/profile.test.ts index 35335b2a..e0150ebb 100644 --- a/packages/frontend/e2e/tests/minimal/profile.test.ts +++ b/packages/frontend/e2e/tests/minimal/profile.test.ts @@ -123,13 +123,13 @@ test.describe('Profile page', () => { await page.getByRole('button', { name: 'Log out' }).click(); // Should redirect to login - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); test('unauthenticated access redirects to login', async ({ page }) => { await page.goto('/profile'); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); }); @@ -169,7 +169,7 @@ test.describe('Change password', () => { await expect(page.locator(modal.openModal)).not.toBeVisible(); await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expectPasswordLoginRejected(page, browserName, email, TEST_PASSWORD); await expectPasswordLoginSucceeds(page, browserName, email, NEW_PASSWORD); diff --git a/packages/frontend/e2e/tests/minimal/register.test.ts b/packages/frontend/e2e/tests/minimal/register.test.ts index 4a56d857..97de9d6d 100644 --- a/packages/frontend/e2e/tests/minimal/register.test.ts +++ b/packages/frontend/e2e/tests/minimal/register.test.ts @@ -74,7 +74,7 @@ test.describe('Registration flow (minimal config)', () => { await page.goto('/register'); await page.locator(registerPage.loginLink).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/packages/frontend/e2e/tests/passkey-optional/profile-passkey-modals.test.ts b/packages/frontend/e2e/tests/passkey-optional/profile-passkey-modals.test.ts index 2a1662fa..6324f68a 100644 --- a/packages/frontend/e2e/tests/passkey-optional/profile-passkey-modals.test.ts +++ b/packages/frontend/e2e/tests/passkey-optional/profile-passkey-modals.test.ts @@ -23,7 +23,7 @@ const TEST_PASSWORD = 'test-password-123'; async function logoutFromProfile(page: Page): Promise { await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); } async function loginWithPasskeyFromMethodPage(page: Page): Promise { diff --git a/packages/frontend/e2e/tests/passkey-required/passkey-flow.test.ts b/packages/frontend/e2e/tests/passkey-required/passkey-flow.test.ts index 364adc32..0a891eb4 100644 --- a/packages/frontend/e2e/tests/passkey-required/passkey-flow.test.ts +++ b/packages/frontend/e2e/tests/passkey-required/passkey-flow.test.ts @@ -44,7 +44,7 @@ async function registerPasskeyFromRequiredSetup( async function logoutFromProfile(page: Page): Promise { await expect(page.getByRole('button', { name: 'Log out' })).toBeVisible(); await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); } const TEST_PASSWORD = 'test-password-123'; @@ -113,7 +113,7 @@ test.describe('Passkey-required flow', () => { await page.waitForURL('**/profile'); await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); const loginRes = await page.request.post( `${String(baseURL)}/api/auth/login`, diff --git a/packages/frontend/e2e/tests/password-disabled/password-disabled.test.ts b/packages/frontend/e2e/tests/password-disabled/password-disabled.test.ts index f5938624..26abd365 100644 --- a/packages/frontend/e2e/tests/password-disabled/password-disabled.test.ts +++ b/packages/frontend/e2e/tests/password-disabled/password-disabled.test.ts @@ -49,7 +49,7 @@ test.describe('Password disabled configuration', () => { browserName, }) => { await gotoWithFirefoxRetry(page, browserName, '/password/forgot'); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); await gotoWithFirefoxRetry( @@ -57,7 +57,7 @@ test.describe('Password disabled configuration', () => { browserName, '/password/reset?token=test-token', ); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); @@ -70,7 +70,7 @@ test.describe('Password disabled configuration', () => { browserName, '/verify/email?token=test-token', ); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); diff --git a/packages/frontend/e2e/tests/registration-disabled/register-disabled.test.ts b/packages/frontend/e2e/tests/registration-disabled/register-disabled.test.ts index bebe724f..59063048 100644 --- a/packages/frontend/e2e/tests/registration-disabled/register-disabled.test.ts +++ b/packages/frontend/e2e/tests/registration-disabled/register-disabled.test.ts @@ -21,7 +21,7 @@ test.describe('Registration disabled', () => { await page.goto('/register'); // beforeLoad guard checks public_registration: false and redirects - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); diff --git a/packages/frontend/e2e/tests/terms-complete-registration/terms-complete-registration.test.ts b/packages/frontend/e2e/tests/terms-complete-registration/terms-complete-registration.test.ts index 970319f2..065b63e2 100644 --- a/packages/frontend/e2e/tests/terms-complete-registration/terms-complete-registration.test.ts +++ b/packages/frontend/e2e/tests/terms-complete-registration/terms-complete-registration.test.ts @@ -49,7 +49,7 @@ test.describe('Terms complete registration mode', () => { page, }) => { await page.goto('/terms'); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await expect(page).toHaveURL(/\/login/); }); diff --git a/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts b/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts index cfc4e5f0..7831b50f 100644 --- a/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts +++ b/packages/frontend/e2e/tests/totp-optional/profile-totp-modals.test.ts @@ -310,7 +310,7 @@ test.describe('DisableTotpModal (profile, optional 2FA)', () => { await expect(page.getByRole('button', { name: 'Enable' })).toBeVisible(); await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await performLogin(page, email, TEST_PASSWORD); await page.waitForURL('**/profile'); diff --git a/packages/frontend/e2e/tests/totp-required/totp-recovery.test.ts b/packages/frontend/e2e/tests/totp-required/totp-recovery.test.ts index 2991ad63..338e8f14 100644 --- a/packages/frontend/e2e/tests/totp-required/totp-recovery.test.ts +++ b/packages/frontend/e2e/tests/totp-required/totp-recovery.test.ts @@ -95,7 +95,7 @@ test.describe('TOTP recovery code verification', () => { await page.waitForURL('**/profile'); await page.getByRole('button', { name: 'Log out' }).click(); - await page.waitForURL('**/login'); + await page.waitForURL('**/login**'); await performLogin(page, email, TEST_PASSWORD); await page.waitForURL('**/verify/totp'); diff --git a/packages/frontend/src/components/auth/authorization-context-banner.tsx b/packages/frontend/src/components/auth/authorization-context-banner.tsx new file mode 100644 index 00000000..036e4474 --- /dev/null +++ b/packages/frontend/src/components/auth/authorization-context-banner.tsx @@ -0,0 +1,89 @@ +import { GlobeSimpleIcon, ShieldCheckIcon } from '@phosphor-icons/react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + type AuthorizationContextSearch, + hasAuthorizationContext, + type OAuthSearch, +} from '#frontend/libs/oauth-search.ts'; +import { getAuthorizationContextQueryOptions } from '#frontend/queries/authorization-context.ts'; + +type AuthorizationContextBannerProps = { + search: OAuthSearch; + className?: string; +}; + +export function AuthorizationContextBanner({ + search, + className = '', +}: AuthorizationContextBannerProps) { + if (!hasAuthorizationContext(search)) { + return null; + } + + return ; +} + +function AuthorizationContextContent({ + search, + className, +}: { + search: AuthorizationContextSearch; + className: string; +}) { + const { t } = useTranslation(); + const { data } = useSuspenseQuery( + getAuthorizationContextQueryOptions(search), + ); + + return ( +
+
+
+ +
+
+

+ {t('authorizationContext.title', { app: data.client.name })} +

+
+ + + {t('authorizationContext.redirect', { + origin: data.redirect_origin, + })} + +
+ +
+

+ {t('authorizationContext.permissions')} +

+ {data.scopes.length > 0 ? ( +
    + {data.scopes.map((scope) => ( +
  • + {t(`consent.scope.${scope.name}`, { + defaultValue: scope.description, + })} +
  • + ))} +
+ ) : ( +

+ {t('authorizationContext.noScopes')} +

+ )} +
+
+
+
+ ); +} diff --git a/packages/frontend/src/components/auth/icon-input.tsx b/packages/frontend/src/components/auth/icon-input.tsx index 84383b95..98f27efb 100644 --- a/packages/frontend/src/components/auth/icon-input.tsx +++ b/packages/frontend/src/components/auth/icon-input.tsx @@ -1,10 +1,13 @@ import type { Icon } from '@phosphor-icons/react'; +import { useId } from 'react'; import type { FieldError } from 'react-hook-form'; type IconInputProps = { icon: Icon; type?: 'email' | 'password' | 'text'; + label: string; placeholder: string; + hint?: string; autoComplete?: string; error?: FieldError; className?: string; @@ -14,33 +17,56 @@ type IconInputProps = { export const IconInput = ({ icon: IconComponent, type = 'text', + label, placeholder, + hint, autoComplete, error, className = '', ref, ...props }: IconInputProps) => { + const generatedId = useId(); + const inputId = props.id ?? generatedId; + const hintId = hint ? `${inputId}-hint` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = errorId ?? hintId; + return (
- +
- + - +
+ {hint && !error && ( +

+ {hint} +

+ )} {error && ( -

+

{error.message}

)} diff --git a/packages/frontend/src/components/terms/terms-checkbox-list.tsx b/packages/frontend/src/components/terms/terms-checkbox-list.tsx index 024ebd1a..27b9e965 100644 --- a/packages/frontend/src/components/terms/terms-checkbox-list.tsx +++ b/packages/frontend/src/components/terms/terms-checkbox-list.tsx @@ -61,6 +61,14 @@ export function TermsCheckboxList({ | Record | undefined; + const getTermTitle = (term: TermItem) => { + if (term.title !== term.id) { + return term.title; + } + + return humanizeTermId(term.id); + }; + return ( <>
@@ -92,59 +100,60 @@ export function TermsCheckboxList({ name={`termsConsents.${term.id}` as Path} render={({ field }) => (
- +
{term.userConsent?.requiresUpdate && (

{t('terms.versionUpdated')} @@ -174,3 +183,16 @@ export function TermsCheckboxList({ ); } + +function humanizeTermId(id: string): string { + const words = id + .replaceAll('-', ' ') + .replaceAll('_', ' ') + .replace(/([a-zA-Z])(\d)/g, '$1 $2') + .split(' ') + .filter((word) => word.length > 0); + + return words + .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`) + .join(' '); +} diff --git a/packages/frontend/src/features/layout/page-layout.tsx b/packages/frontend/src/features/layout/page-layout.tsx index 91c2f6c3..d79281be 100644 --- a/packages/frontend/src/features/layout/page-layout.tsx +++ b/packages/frontend/src/features/layout/page-layout.tsx @@ -42,11 +42,11 @@ export function PageLayout({ const backgroundUrl = configData.branding.background_url; const containerClass = responsivePadding ? 'p-4 md:p-8' : 'p-4'; - const cardClass = `${maxWidthClasses[maxWidth]}${cardPadding ? ' p-10' : ''}`; + const cardClass = `${maxWidthClasses[maxWidth]}${cardPadding ? ' p-6 sm:p-10' : ''}`; return (

+ {backgroundUrl && ( +
+ )} {canToggleTheme && ( )} -
+
{children}
- +
); } diff --git a/packages/frontend/src/features/profile/manage-passkeys-modal.test.tsx b/packages/frontend/src/features/profile/manage-passkeys-modal.test.tsx index 0e18a1a9..29876ab6 100644 --- a/packages/frontend/src/features/profile/manage-passkeys-modal.test.tsx +++ b/packages/frontend/src/features/profile/manage-passkeys-modal.test.tsx @@ -45,7 +45,7 @@ test('renames a passkey and refreshes passkey data', async () => { await screen.getByText('Save').click(); await vi.waitFor(() => { - expect(fetchMock.requests.length).toBeGreaterThanOrEqual(2); + expect(fetchMock.requests.length).toBeGreaterThanOrEqual(3); }); const request = fetchMock.requests[1]; diff --git a/packages/frontend/src/i18n/locales/en.json b/packages/frontend/src/i18n/locales/en.json index f493f9ba..e1f00eeb 100644 --- a/packages/frontend/src/i18n/locales/en.json +++ b/packages/frontend/src/i18n/locales/en.json @@ -8,8 +8,15 @@ "common.yes": "Yes", "common.no": "No", "common.none": "None", + "authorizationContext.label": "Authorization request", + "authorizationContext.title": "Continue to {{app}}", + "authorizationContext.redirect": "Returns to {{origin}}", + "authorizationContext.permissions": "Requested access", + "authorizationContext.noScopes": "No additional profile access requested.", + "login.email.label": "Email address", "login.email.placeholder": "hello@example.com", "login.error.failed": "Login failed. Please check your email and password.", + "login.password.label": "Password", "login.password.placeholder": "Enter your password", "login.submit": "Log in", "login.submitting": "Logging in...", @@ -64,12 +71,15 @@ "admin.users.deleted": "Deleted", "admin.users.total": "{{count}} users", "admin.users.open": "Open users", + "register.email.label": "Email address", "register.email.placeholder": "hello@example.com", "register.error.emailExists": "Email already exists", "register.error.emailNotAllowed": "This email address is not allowed for registration.", "register.error.registrationDisabled": "Registration is currently disabled.", "register.link.login": "Sign in", "register.footer.haveAccount": "Already have an account?", + "register.password.label": "Password", + "register.password.hint": "Use at least {{count}} characters.", "register.password.placeholder": "Enter your password", "register.submit": "Create account", "register.submitting": "Creating account...", @@ -88,6 +98,7 @@ "register.success.description": "Please check your email at {{email}} to verify your account.", "verifyEmail.title": "Email Verification", "verifyEmail.subtitle": "Enter your verification token below", + "verifyEmail.token.label": "Verification token", "verifyEmail.token.placeholder": "Enter the token from your email", "verifyEmail.submit": "Verify", "verifyEmail.submitting": "Verifying...", @@ -101,6 +112,7 @@ "verifyEmail.resendSuccess": "Verification email has been resent", "forgotPassword.title": "Forgot Password", "forgotPassword.subtitle": "Enter your email to receive a password reset link", + "forgotPassword.email.label": "Email address", "forgotPassword.email.placeholder": "hello@example.com", "forgotPassword.submit": "Send reset link", "forgotPassword.submitting": "Sending...", @@ -113,8 +125,11 @@ "forgotPassword.error.notEditable": "This account cannot be modified", "resetPassword.title": "Reset Password", "resetPassword.subtitle": "Enter your new password below", + "resetPassword.token.label": "Reset token", "resetPassword.token.placeholder": "Enter the token from your email", + "resetPassword.password.label": "New password", "resetPassword.password.placeholder": "Enter your new password", + "resetPassword.confirmPassword.label": "Confirm new password", "resetPassword.confirmPassword.placeholder": "Confirm your new password", "resetPassword.submit": "Reset password", "resetPassword.submitting": "Resetting...", @@ -406,7 +421,9 @@ "terms.error.message": "Unable to load terms information.", "terms.error.back": "Go back", "terms.error.submitFailed": "Failed to submit consent. Please try again.", + "terms.additionalOptionalConsent": "Additional optional consent", "terms.view": "View", + "terms.viewSpecific": "View {{title}}", "admin.users.includeDeleted": "Include deleted", "admin.users.search": "Search", "admin.users.actions": "Actions", diff --git a/packages/frontend/src/i18n/locales/ja.json b/packages/frontend/src/i18n/locales/ja.json index 67c070d7..96621716 100644 --- a/packages/frontend/src/i18n/locales/ja.json +++ b/packages/frontend/src/i18n/locales/ja.json @@ -8,8 +8,15 @@ "common.yes": "はい", "common.no": "いいえ", "common.none": "なし", + "authorizationContext.label": "認可リクエスト", + "authorizationContext.title": "{{app}}に続行", + "authorizationContext.redirect": "{{origin}}に戻ります", + "authorizationContext.permissions": "要求されたアクセス", + "authorizationContext.noScopes": "追加のプロフィールアクセスは要求されていません。", + "login.email.label": "メールアドレス", "login.email.placeholder": "hello@example.com", "login.error.failed": "ログインに失敗しました。メールアドレスとパスワードを確認してください。", + "login.password.label": "パスワード", "login.password.placeholder": "パスワードを入力", "login.submit": "ログイン", "login.submitting": "ログイン中...", @@ -64,12 +71,15 @@ "admin.users.deleted": "削除済み", "admin.users.total": "{{count}} ユーザー", "admin.users.open": "ユーザーを開く", + "register.email.label": "メールアドレス", "register.email.placeholder": "hello@example.com", "register.error.emailExists": "メールアドレスはすでに存在します", "register.error.emailNotAllowed": "このメールアドレスでの登録は許可されていません。", "register.error.registrationDisabled": "現在、登録は無効になっています。", "register.link.login": "ログイン", "register.footer.haveAccount": "すでにアカウントをお持ちですか?", + "register.password.label": "パスワード", + "register.password.hint": "{{count}}文字以上で入力してください。", "register.password.placeholder": "パスワードを入力", "register.submit": "アカウント作成", "register.submitting": "アカウント作成中...", @@ -88,6 +98,7 @@ "register.success.description": "{{email}}に送信されたメールを確認してアカウントを認証してください。", "verifyEmail.title": "メール認証", "verifyEmail.subtitle": "以下に認証トークンを入力してください", + "verifyEmail.token.label": "認証トークン", "verifyEmail.token.placeholder": "メールで受け取ったトークンを入力", "verifyEmail.submit": "認証する", "verifyEmail.submitting": "認証中...", @@ -101,6 +112,7 @@ "verifyEmail.resendSuccess": "認証メールを再送信しました", "forgotPassword.title": "パスワードを忘れた", "forgotPassword.subtitle": "パスワードリセットリンクを受け取るメールアドレスを入力してください", + "forgotPassword.email.label": "メールアドレス", "forgotPassword.email.placeholder": "hello@example.com", "forgotPassword.submit": "リセットリンクを送信", "forgotPassword.submitting": "送信中...", @@ -113,8 +125,11 @@ "forgotPassword.error.notEditable": "このアカウントは変更できません", "resetPassword.title": "パスワードリセット", "resetPassword.subtitle": "新しいパスワードを入力してください", + "resetPassword.token.label": "リセットトークン", "resetPassword.token.placeholder": "メールで受け取ったトークンを入力", + "resetPassword.password.label": "新しいパスワード", "resetPassword.password.placeholder": "新しいパスワードを入力", + "resetPassword.confirmPassword.label": "新しいパスワードの確認", "resetPassword.confirmPassword.placeholder": "新しいパスワードを再入力", "resetPassword.submit": "パスワードをリセット", "resetPassword.submitting": "リセット中...", @@ -406,7 +421,9 @@ "terms.error.message": "規約情報を読み込めませんでした。", "terms.error.back": "戻る", "terms.error.submitFailed": "同意の送信に失敗しました。もう一度お試しください。", + "terms.additionalOptionalConsent": "追加の任意同意", "terms.view": "表示", + "terms.viewSpecific": "{{title}}を表示", "admin.users.includeDeleted": "削除済みを含める", "admin.users.search": "検索", "admin.users.actions": "操作", diff --git a/packages/frontend/src/i18n/locales/ko.json b/packages/frontend/src/i18n/locales/ko.json index d73615a7..514c14ed 100644 --- a/packages/frontend/src/i18n/locales/ko.json +++ b/packages/frontend/src/i18n/locales/ko.json @@ -8,8 +8,15 @@ "common.yes": "예", "common.no": "아니오", "common.none": "없음", + "authorizationContext.label": "권한 요청", + "authorizationContext.title": "{{app}}로 계속", + "authorizationContext.redirect": "{{origin}}로 돌아갑니다", + "authorizationContext.permissions": "요청된 접근 권한", + "authorizationContext.noScopes": "추가 프로필 접근 권한을 요청하지 않았습니다.", + "login.email.label": "이메일 주소", "login.email.placeholder": "hello@example.com", "login.error.failed": "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.", + "login.password.label": "비밀번호", "login.password.placeholder": "비밀번호를 입력하세요", "login.submit": "로그인", "login.submitting": "로그인 중...", @@ -64,12 +71,15 @@ "admin.users.deleted": "삭제됨", "admin.users.total": "사용자 {{count}}명", "admin.users.open": "사용자 열기", + "register.email.label": "이메일 주소", "register.email.placeholder": "hello@example.com", "register.error.emailExists": "이미 존재하는 이메일입니다", "register.error.emailNotAllowed": "이 이메일 주소로는 회원가입이 허용되지 않습니다.", "register.error.registrationDisabled": "현재 회원가입이 비활성화되어 있습니다.", "register.link.login": "로그인", "register.footer.haveAccount": "이미 계정이 있으신가요?", + "register.password.label": "비밀번호", + "register.password.hint": "최소 {{count}}자 이상 입력하세요.", "register.password.placeholder": "비밀번호를 입력하세요", "register.submit": "회원가입", "register.submitting": "회원가입 중...", @@ -88,6 +98,7 @@ "register.success.description": "{{email}}로 전송된 이메일을 확인하여 계정을 인증해주세요.", "verifyEmail.title": "이메일 인증", "verifyEmail.subtitle": "아래에 인증 토큰을 입력하세요", + "verifyEmail.token.label": "인증 토큰", "verifyEmail.token.placeholder": "이메일로 받은 토큰을 입력하세요", "verifyEmail.submit": "인증하기", "verifyEmail.submitting": "인증 중...", @@ -101,6 +112,7 @@ "verifyEmail.resendSuccess": "인증 이메일이 재전송되었습니다", "forgotPassword.title": "비밀번호 찾기", "forgotPassword.subtitle": "비밀번호 재설정 링크를 받을 이메일을 입력하세요", + "forgotPassword.email.label": "이메일 주소", "forgotPassword.email.placeholder": "hello@example.com", "forgotPassword.submit": "재설정 링크 보내기", "forgotPassword.submitting": "전송 중...", @@ -113,8 +125,11 @@ "forgotPassword.error.notEditable": "이 계정은 수정할 수 없습니다", "resetPassword.title": "비밀번호 재설정", "resetPassword.subtitle": "새 비밀번호를 입력하세요", + "resetPassword.token.label": "재설정 토큰", "resetPassword.token.placeholder": "이메일로 받은 토큰을 입력하세요", + "resetPassword.password.label": "새 비밀번호", "resetPassword.password.placeholder": "새 비밀번호를 입력하세요", + "resetPassword.confirmPassword.label": "새 비밀번호 확인", "resetPassword.confirmPassword.placeholder": "새 비밀번호를 다시 입력하세요", "resetPassword.submit": "비밀번호 재설정", "resetPassword.submitting": "재설정 중...", @@ -406,7 +421,9 @@ "terms.error.message": "약관 정보를 불러올 수 없습니다.", "terms.error.back": "돌아가기", "terms.error.submitFailed": "약관 동의 처리에 실패했습니다. 다시 시도해주세요.", + "terms.additionalOptionalConsent": "추가 선택 동의", "terms.view": "보기", + "terms.viewSpecific": "{{title}} 보기", "admin.users.includeDeleted": "삭제된 사용자 포함", "admin.users.search": "검색", "admin.users.actions": "작업", diff --git a/packages/frontend/src/libs/oauth-search.ts b/packages/frontend/src/libs/oauth-search.ts index fd7616a5..16149eee 100644 --- a/packages/frontend/src/libs/oauth-search.ts +++ b/packages/frontend/src/libs/oauth-search.ts @@ -65,6 +65,12 @@ export const OAuthSearchSchema = z.object({ export type OAuthSearch = z.infer; +export type AuthorizationContextSearch = OAuthSearch & { + client_id: string; + redirect_uri: string; + response_type: string; +}; + /** Second factor method type */ export type SecondFactorMethod = 'totp' | 'passkey'; @@ -76,6 +82,12 @@ export function isOAuthFlow(search: OAuthSearch): boolean { return !!(search.client_id && search.redirect_uri); } +export function hasAuthorizationContext( + search: OAuthSearch, +): search is AuthorizationContextSearch { + return !!(search.client_id && search.redirect_uri && search.response_type); +} + /** * OAuth authorize URL을 빌드하는 헬퍼 함수 * 로그인/회원가입 성공 후 OAuth 플로우를 재개할 때 사용 diff --git a/packages/frontend/src/queries/authorization-context.ts b/packages/frontend/src/queries/authorization-context.ts new file mode 100644 index 00000000..73083862 --- /dev/null +++ b/packages/frontend/src/queries/authorization-context.ts @@ -0,0 +1,37 @@ +import { queryOptions } from '@tanstack/react-query'; +import type { InferResponseType } from 'hono/client'; +import { client, jsonOk } from '#frontend/libs/api.ts'; +import type { AuthorizationContextSearch } from '#frontend/libs/oauth-search.ts'; +import { queryKeys } from './keys'; + +export type AuthorizationContextResponse = InferResponseType< + (typeof client.api.oauth)['authorization-context']['$get'], + 200 +>; + +export type AuthorizationContextScope = + AuthorizationContextResponse['scopes'][number]; + +export const getAuthorizationContextQueryOptions = ( + search: AuthorizationContextSearch, +) => + queryOptions({ + queryKey: queryKeys.oauth.authorizationContext( + search.client_id, + search.redirect_uri, + search.response_type, + search.scope, + ), + queryFn: async () => { + const res = await client.api.oauth['authorization-context'].$get({ + query: { + client_id: search.client_id, + redirect_uri: search.redirect_uri, + response_type: search.response_type, + scope: search.scope, + }, + }); + return jsonOk(res); + }, + staleTime: 1000 * 60, + }); diff --git a/packages/frontend/src/queries/keys.ts b/packages/frontend/src/queries/keys.ts index 1898a87e..54d4feaf 100644 --- a/packages/frontend/src/queries/keys.ts +++ b/packages/frontend/src/queries/keys.ts @@ -20,6 +20,19 @@ export const queryKeys = { // OAuth oauth: { accounts: () => ['/api/user/oauth-accounts'] as const, + authorizationContext: ( + clientId: string, + redirectUri: string, + responseType: string, + scope?: string, + ) => + [ + '/api/oauth/authorization-context', + clientId, + redirectUri, + responseType, + scope, + ] as const, }, // 동의 (Consent) diff --git a/packages/frontend/src/routes/login/-index.test.tsx b/packages/frontend/src/routes/login/-index.test.tsx index 4551ad59..a29a3040 100644 --- a/packages/frontend/src/routes/login/-index.test.tsx +++ b/packages/frontend/src/routes/login/-index.test.tsx @@ -6,7 +6,10 @@ import { mockJsonResponses, resetFetchMock, } from '#frontend/test-utils/query-test-utils.ts'; -import { renderRoute } from '#frontend/test-utils/route-test-utils.tsx'; +import { + authorizationContextQueryData, + renderRoute, +} from '#frontend/test-utils/route-test-utils.tsx'; vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn(), @@ -75,6 +78,16 @@ const baseConfig = { }, } satisfies AppConfigs; +const oauthSearch = { + client_id: 'client-web', + redirect_uri: 'https://client.example/callback', + response_type: 'code', + scope: 'openid', +}; + +const oauthLocation = + '/login?client_id=client-web&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback&response_type=code&scope=openid&state=state-123&code_challenge=challenge&code_challenge_method=S256'; + function seedConfig(config: AppConfigs = baseConfig) { return [ { @@ -84,6 +97,10 @@ function seedConfig(config: AppConfigs = baseConfig) { ]; } +function seedOAuthRouteData(config: AppConfigs = baseConfig) { + return [...seedConfig(config), authorizationContextQueryData(oauthSearch)]; +} + afterEach(() => { vi.mocked(startAuthentication).mockReset(); resetFetchMock(); @@ -91,14 +108,15 @@ afterEach(() => { describe('/login', () => { test('shows configured OAuth, password, and passkey auth methods', async () => { - const initialLocation = - '/login?client_id=client-web&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback&response_type=code&scope=openid&state=state-123&code_challenge=challenge&code_challenge_method=S256'; - const { screen } = await renderRoute({ - initialLocation, - queryData: seedConfig(), + initialLocation: oauthLocation, + queryData: seedOAuthRouteData(), }); + await expect + .element(screen.getByTestId('authorization-context')) + .toBeVisible(); + await expect .element(screen.getByRole('link', { name: 'GitHub' })) .toBeVisible(); @@ -125,6 +143,26 @@ describe('/login', () => { ); }); + test('skips the method picker when email password is the only login method', async () => { + const passwordOnlyConfig = { + ...baseConfig, + auth: { + ...baseConfig.auth, + passkey: { + enabled: false, + }, + }, + identity_providers: [], + } satisfies AppConfigs; + + const { router } = await renderRoute({ + initialLocation: '/login', + queryData: seedConfig(passwordOnlyConfig), + }); + + expect(router.state.location.pathname).toBe('/login/password'); + }); + test('hides disabled password auth while keeping enabled passkey auth available', async () => { const passwordDisabledConfig = { ...baseConfig, diff --git a/packages/frontend/src/routes/login/index.tsx b/packages/frontend/src/routes/login/index.tsx index 844c186b..511520c4 100644 --- a/packages/frontend/src/routes/login/index.tsx +++ b/packages/frontend/src/routes/login/index.tsx @@ -8,10 +8,11 @@ import { useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; -import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; +import { AuthorizationContextBanner } from '#frontend/components/auth/authorization-context-banner.tsx'; import { LoginMethodButton } from '#frontend/components/auth/login-method-button.tsx'; import { LoginMethodList } from '#frontend/components/auth/login-method-list.tsx'; import { PageHeader } from '#frontend/components/auth/page-header.tsx'; @@ -21,12 +22,17 @@ import { PageLayout } from '#frontend/features/layout/page-layout.tsx'; import { buildAuthenticatedAuthorizeUrl, extractOAuthParams, + hasAuthorizationContext, isOAuthFlow, OAuthSearchSchema, } from '#frontend/libs/oauth-search.ts'; import { classifyPasskeyError } from '#frontend/libs/passkey-error.ts'; import { tick } from '#frontend/libs/promise.ts'; -import { appConfigQueryOptions } from '#frontend/queries/config.ts'; +import { getAuthorizationContextQueryOptions } from '#frontend/queries/authorization-context.ts'; +import { + type AppConfigs, + appConfigQueryOptions, +} from '#frontend/queries/config.ts'; import { authenticateWithPasskeyMutationOptions } from '#frontend/queries/passkey.ts'; import { type AuthResponse, @@ -49,11 +55,45 @@ export const Route = createFileRoute('/login/')({ component: Login, errorComponent: RouteErrorFallback, validateSearch: SearchSchema, - loader: async ({ context }) => { - await context.queryClient.ensureQueryData(appConfigQueryOptions); + loaderDeps: ({ search }) => ({ + search, + }), + loader: async ({ context, deps }) => { + const config = await context.queryClient.ensureQueryData( + appConfigQueryOptions, + ); + + if (shouldRedirectToPassword(config, deps.search)) { + throw redirect({ + to: '/login/password', + search: extractOAuthParams(deps.search), + replace: true, + }); + } + + if (hasAuthorizationContext(deps.search)) { + await context.queryClient.ensureQueryData( + getAuthorizationContextQueryOptions(deps.search), + ); + } }, }); +function shouldRedirectToPassword( + config: AppConfigs, + search: z.infer, +): boolean { + if (search.oauth_error) { + return false; + } + + return ( + config.auth.password.enabled && + !config.auth.passkey.enabled && + config.identity_providers.length === 0 + ); +} + function Login() { const { t, i18n } = useTranslation(); const router = useRouter(); @@ -161,6 +201,8 @@ function Login() { title={customTitle ?? t('login.title')} /> + + {oauthErrorMessage && ( {oauthErrorMessage} diff --git a/packages/frontend/src/routes/login/password/-index.test.tsx b/packages/frontend/src/routes/login/password/-index.test.tsx index bb34f778..22b776da 100644 --- a/packages/frontend/src/routes/login/password/-index.test.tsx +++ b/packages/frontend/src/routes/login/password/-index.test.tsx @@ -9,6 +9,7 @@ import { resetFetchMock, } from '#frontend/test-utils/query-test-utils.ts'; import { + authorizationContextQueryData, renderRoute, routeTestUser, } from '#frontend/test-utils/route-test-utils.tsx'; @@ -72,6 +73,13 @@ const baseConfig = { const oauthLocation = '/login/password?client_id=client-web&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback&response_type=code&scope=openid&state=state-123&nonce=nonce-123&code_challenge=challenge&code_challenge_method=S256'; +const oauthSearch = { + client_id: 'client-web', + redirect_uri: 'https://client.example/callback', + response_type: 'code', + scope: 'openid', +}; + function seedConfig(config: AppConfigs = baseConfig) { return [ { @@ -81,6 +89,10 @@ function seedConfig(config: AppConfigs = baseConfig) { ]; } +function seedOAuthRouteData(config: AppConfigs = baseConfig) { + return [...seedConfig(config), authorizationContextQueryData(oauthSearch)]; +} + function authResponse(): AuthResponse { return { user: { @@ -132,9 +144,13 @@ describe('/login/password', () => { const { router, screen } = await renderRoute({ initialLocation: oauthLocation, - queryData: seedConfig(), + queryData: seedOAuthRouteData(), }); + await expect + .element(screen.getByTestId('authorization-context')) + .toBeVisible(); + await submitPasswordLogin(screen); await vi.waitFor(() => { diff --git a/packages/frontend/src/routes/login/password/index.tsx b/packages/frontend/src/routes/login/password/index.tsx index 45b24804..7c8f84bf 100644 --- a/packages/frontend/src/routes/login/password/index.tsx +++ b/packages/frontend/src/routes/login/password/index.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; +import { AuthorizationContextBanner } from '#frontend/components/auth/authorization-context-banner.tsx'; import { FooterLink } from '#frontend/components/auth/footer-link.tsx'; import { IconInput } from '#frontend/components/auth/icon-input.tsx'; import { PageHeader } from '#frontend/components/auth/page-header.tsx'; @@ -19,11 +20,13 @@ import { PageLayout } from '#frontend/features/layout/page-layout.tsx'; import { buildAuthenticatedAuthorizeUrl, extractOAuthParams, + hasAuthorizationContext, isOAuthFlow, OAuthSearchSchema, type SecondFactorMethod, } from '#frontend/libs/oauth-search.ts'; import { tick } from '#frontend/libs/promise.ts'; +import { getAuthorizationContextQueryOptions } from '#frontend/queries/authorization-context.ts'; import { appConfigQueryOptions } from '#frontend/queries/config.ts'; import { loginMutationOptions } from '#frontend/queries/login.ts'; import { startConditionalPasskeyAuth } from '#frontend/queries/passkey.ts'; @@ -38,8 +41,16 @@ export const Route = createFileRoute('/login/password/')({ component: LoginPassword, errorComponent: RouteErrorFallback, validateSearch: SearchSchema, - loader: async ({ context }) => { + loaderDeps: ({ search }) => ({ + search, + }), + loader: async ({ context, deps }) => { await context.queryClient.ensureQueryData(appConfigQueryOptions); + if (hasAuthorizationContext(deps.search)) { + await context.queryClient.ensureQueryData( + getAuthorizationContextQueryOptions(deps.search), + ); + } }, }); @@ -63,8 +74,16 @@ function LoginPassword() { const customSubtitle = configData.branding.subtitle?.[lang] ?? configData.branding.subtitle?.[configData.i18n.fallback_language]; + const implicitNotice = + configData.registration.signup_notice?.[lang] ?? + configData.registration.signup_notice?.[configData.i18n.fallback_language]; const isPasswordAuthEnabled = configData.auth.password.enabled; const isPasskeyEnabled = configData.auth.passkey.enabled; + const hasMultipleLoginMethods = + configData.identity_providers.length + + (isPasswordAuthEnabled ? 1 : 0) + + (isPasskeyEnabled ? 1 : 0) > + 1; const loginSchema = useMemo( () => @@ -233,12 +252,15 @@ function LoginPassword() { title={customTitle ?? t('login.title')} /> + + {isPasswordAuthEnabled && (
)} + {implicitNotice && ( +
+
+
+ )} + {configData.registration.public_registration && ( )} - {/*
- - {t('login.password.backToMethods')} - -
*/} + {hasMultipleLoginMethods && ( +
+ + {t('login.password.backToMethods')} + +
+ )} ); } diff --git a/packages/frontend/src/routes/password/forgot/index.tsx b/packages/frontend/src/routes/password/forgot/index.tsx index 30d9a446..fa4ec86e 100644 --- a/packages/frontend/src/routes/password/forgot/index.tsx +++ b/packages/frontend/src/routes/password/forgot/index.tsx @@ -130,6 +130,7 @@ function ForgotPassword() { autoComplete="email" error={errors.email} icon={EnvelopeSimpleIcon} + label={t('forgotPassword.email.label')} placeholder={t('forgotPassword.email.placeholder')} {...register('email')} type="email" diff --git a/packages/frontend/src/routes/password/reset/index.tsx b/packages/frontend/src/routes/password/reset/index.tsx index 9c34da43..b085842e 100644 --- a/packages/frontend/src/routes/password/reset/index.tsx +++ b/packages/frontend/src/routes/password/reset/index.tsx @@ -177,6 +177,7 @@ function ResetPassword() { { const { router, screen } = await renderRoute({ initialLocation: oauthLocation, - queryData: seedRouteData(), + queryData: seedOAuthRouteData(), }); + await expect + .element(screen.getByTestId('authorization-context')) + .toBeVisible(); + await submitRegister(screen); await vi.waitFor(() => { diff --git a/packages/frontend/src/routes/register/index.tsx b/packages/frontend/src/routes/register/index.tsx index ae1ead81..bb32d417 100644 --- a/packages/frontend/src/routes/register/index.tsx +++ b/packages/frontend/src/routes/register/index.tsx @@ -15,6 +15,7 @@ import { useDeferredValue, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; +import { AuthorizationContextBanner } from '#frontend/components/auth/authorization-context-banner.tsx'; import { FooterLink } from '#frontend/components/auth/footer-link.tsx'; import { IconInput } from '#frontend/components/auth/icon-input.tsx'; import { PageHeader } from '#frontend/components/auth/page-header.tsx'; @@ -26,11 +27,13 @@ import { TinyAuthError } from '#frontend/libs/error.ts'; import { buildAuthenticatedAuthorizeUrl, extractOAuthParams, + hasAuthorizationContext, isOAuthFlow, OAuthSearchSchema, type SecondFactorMethod, } from '#frontend/libs/oauth-search.ts'; import { tick } from '#frontend/libs/promise.ts'; +import { getAuthorizationContextQueryOptions } from '#frontend/queries/authorization-context.ts'; import { appConfigQueryOptions } from '#frontend/queries/config.ts'; import { registerMutationOptions } from '#frontend/queries/register.ts'; import { getSessionQueryOptions } from '#frontend/queries/session.ts'; @@ -53,13 +56,24 @@ export const Route = createFileRoute('/register/')({ }, loaderDeps: ({ search }) => ({ lang: search.lang, + search, }), loader: async ({ context, deps }) => { const lang = deps.lang ?? context.i18n.language; - await Promise.all([ + const queries: Array> = [ context.queryClient.ensureQueryData(appConfigQueryOptions), context.queryClient.ensureQueryData(getTermsQueryOptions(lang)), - ]); + ]; + + if (hasAuthorizationContext(deps.search)) { + queries.push( + context.queryClient.ensureQueryData( + getAuthorizationContextQueryOptions(deps.search), + ), + ); + } + + await Promise.all(queries); }, }); @@ -263,12 +277,15 @@ function Register() { title={t('register.title')} /> + + {isPasswordAuthEnabled && ( AND
+
+ {t('terms.additionalOptionalConsent')} +
)} {hasTerms && hasExplicitTerms && ( diff --git a/packages/frontend/src/routes/terms/index.tsx b/packages/frontend/src/routes/terms/index.tsx index 829c1a00..880c8735 100644 --- a/packages/frontend/src/routes/terms/index.tsx +++ b/packages/frontend/src/routes/terms/index.tsx @@ -206,7 +206,9 @@ function Terms() { )} {implicitNotice && hasExplicitTerms && ( -
AND
+
+ {t('terms.additionalOptionalConsent')} +
)} diff --git a/packages/frontend/src/routes/verify/email/index.tsx b/packages/frontend/src/routes/verify/email/index.tsx index e34de99a..e193d127 100644 --- a/packages/frontend/src/routes/verify/email/index.tsx +++ b/packages/frontend/src/routes/verify/email/index.tsx @@ -221,6 +221,7 @@ function VerifyEmail() { { const initialLocation = '/login?client_id=client-web&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback&response_type=code&scope=openid&state=state-123&code_challenge=challenge&code_challenge_method=S256'; @@ -72,6 +82,7 @@ test('renders a route with seeded query data and preserves the initial search', queryKey: appConfigQueryOptions.queryKey, data: loginConfig, }, + authorizationContextQueryData(oauthSearch), ], }); diff --git a/packages/frontend/src/test-utils/route-test-utils.tsx b/packages/frontend/src/test-utils/route-test-utils.tsx index babd86b0..4d45fad0 100644 --- a/packages/frontend/src/test-utils/route-test-utils.tsx +++ b/packages/frontend/src/test-utils/route-test-utils.tsx @@ -7,7 +7,12 @@ import { import type { RenderResult } from 'vitest-browser-react'; import { render } from 'vitest-browser-react'; import i18n from '#frontend/i18n/index.ts'; +import type { AuthorizationContextSearch } from '#frontend/libs/oauth-search.ts'; import type { AppRouterContext } from '#frontend/libs/router.ts'; +import { + type AuthorizationContextResponse, + getAuthorizationContextQueryOptions, +} from '#frontend/queries/authorization-context.ts'; import type { AppConfigs } from '#frontend/queries/config.ts'; import { appConfigQueryOptions } from '#frontend/queries/config.ts'; import type { SessionUser } from '#frontend/queries/session.ts'; @@ -89,6 +94,22 @@ export const routeTestUser = { passkey_count: 0, } satisfies SessionUser; +export const routeTestAuthorizationContext = { + client: { + id: 'client-1', + clientId: 'client-web', + name: 'Client Web', + }, + redirect_uri: 'https://client.example/callback', + redirect_origin: 'https://client.example', + scopes: [ + { + name: 'openid', + description: 'Access your unique user identifier', + }, + ], +} satisfies AuthorizationContextResponse; + export function appConfigQueryData( config: AppConfigs = routeTestAppConfig, ): RouteTestQueryData { @@ -98,6 +119,16 @@ export function appConfigQueryData( }; } +export function authorizationContextQueryData( + search: AuthorizationContextSearch, + data: AuthorizationContextResponse = routeTestAuthorizationContext, +): RouteTestQueryData { + return { + queryKey: getAuthorizationContextQueryOptions(search).queryKey, + data, + }; +} + type RenderRouteOptions = { initialLocation?: string; queryClient?: QueryClient; diff --git a/packages/server/src/lib/scopes.ts b/packages/server/src/lib/scopes.ts index a3fbcdcb..b209371d 100644 --- a/packages/server/src/lib/scopes.ts +++ b/packages/server/src/lib/scopes.ts @@ -31,8 +31,11 @@ export const parseScopesWithDescriptions = ( ): Array<{ name: string; description: string }> => { if (!scopeString) return []; - return scopeString.split(' ').map((name) => ({ - name, - description: getScopeDescription(name), - })); + return scopeString + .split(' ') + .filter((name) => name.length > 0) + .map((name) => ({ + name, + description: getScopeDescription(name), + })); }; diff --git a/packages/server/src/routes/api/oauth/authorization-context/get.test.ts b/packages/server/src/routes/api/oauth/authorization-context/get.test.ts new file mode 100644 index 00000000..3efc1a31 --- /dev/null +++ b/packages/server/src/routes/api/oauth/authorization-context/get.test.ts @@ -0,0 +1,81 @@ +import { testClient } from 'hono/testing'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import type { AppType } from '../../../../entrypoints/app.ts'; +import { + assertJsonBody, + createTestApp, + MINIMAL_TEST_CONFIG, + TEST_OAUTH_CLIENT, + TEST_OAUTH_CLIENT_CONFIG, +} from '../../../../test-utils/index.ts'; + +let app: AppType; +let cleanup: () => Promise; + +beforeAll(async () => { + const server = await createTestApp({ + ...MINIMAL_TEST_CONFIG, + clients: [TEST_OAUTH_CLIENT_CONFIG], + }); + app = server.app; + cleanup = server.cleanup; +}); + +afterAll(async () => { + await cleanup(); +}); + +describe('GET /api/oauth/authorization-context', () => { + test('returns validated client, redirect, and scope context', async () => { + const client = testClient(app); + const res = await client.api.oauth['authorization-context'].$get({ + query: { + client_id: TEST_OAUTH_CLIENT.clientId, + redirect_uri: TEST_OAUTH_CLIENT.redirectUri, + response_type: 'code', + scope: 'openid profile email', + }, + }); + + const body = await assertJsonBody(res, 200); + expect(body.client.clientId).toBe(TEST_OAUTH_CLIENT.clientId); + expect(body.client.name).toBe('My App'); + expect(body.redirect_uri).toBe(TEST_OAUTH_CLIENT.redirectUri); + expect(body.redirect_origin).toBe('http://localhost:8080'); + expect(body.scopes.map((scope) => scope.name)).toEqual([ + 'openid', + 'profile', + 'email', + ]); + }); + + test('rejects an unregistered redirect URI', async () => { + const client = testClient(app); + const res = await client.api.oauth['authorization-context'].$get({ + query: { + client_id: TEST_OAUTH_CLIENT.clientId, + redirect_uri: 'https://evil.example/callback', + response_type: 'code', + scope: 'openid', + }, + }); + + const body = await assertJsonBody(res, 400); + expect(body.code).toBe('INVALID_REDIRECT_URI'); + }); + + test('rejects unsupported scopes', async () => { + const client = testClient(app); + const res = await client.api.oauth['authorization-context'].$get({ + query: { + client_id: TEST_OAUTH_CLIENT.clientId, + redirect_uri: TEST_OAUTH_CLIENT.redirectUri, + response_type: 'code', + scope: 'openid admin', + }, + }); + + const body = await assertJsonBody(res, 400); + expect(body.code).toBe('INVALID_SCOPE'); + }); +}); diff --git a/packages/server/src/routes/api/oauth/authorization-context/get.ts b/packages/server/src/routes/api/oauth/authorization-context/get.ts new file mode 100644 index 00000000..4ea10c9d --- /dev/null +++ b/packages/server/src/routes/api/oauth/authorization-context/get.ts @@ -0,0 +1,88 @@ +import { Hono } from 'hono'; +import { describeRoute, resolver, validator } from 'hono-openapi'; +import { z } from 'zod'; +import type { AppEnv } from '../../../../lib/app-env.ts'; +import { parseScopesWithDescriptions } from '../../../../lib/scopes.ts'; +import { e } from '../../../../schemas/error.ts'; +import { f } from '../../../../schemas/field.ts'; +import { r } from '../../../../schemas/response.ts'; + +/** + * GET /api/oauth/authorization-context + * + * Returns a server-validated summary of an OAuth/OIDC authorization request. + * This is safe for pre-authentication UI because it only returns data after + * client, redirect URI, response type, and scopes have been validated. + */ +export const authorizationContextGet = new Hono().get( + '/oauth/authorization-context', + describeRoute({ + tags: ['OAuth'], + summary: 'Get authorization request context', + description: + 'Returns validated OAuth client and scope information for login and registration screens.', + responses: { + 200: { + content: { + 'application/json': { + schema: resolver(r.AuthorizationContextResponse), + }, + }, + description: 'Success', + }, + 400: { + content: { + 'application/json': { + schema: resolver( + z.union([ + e.OAuthClientNotFound.Schema, + e.OAuthClientDisabled.Schema, + e.InvalidRedirectUri.Schema, + e.UnsupportedResponseType.Schema, + e.InvalidScope.Schema, + ]), + ), + }, + }, + description: 'Invalid authorization request context', + }, + }, + }), + validator( + 'query', + z.object({ + client_id: f.clientId, + redirect_uri: f.redirectUri, + response_type: f.responseType, + scope: f.scope.optional(), + }), + ), + async (c) => { + const query = c.req.valid('query'); + const { oauthClientService } = c.var.services; + + const client = await oauthClientService.findByClientId(query.client_id); + oauthClientService.validateEnabled(client); + oauthClientService.validateRedirectUri(client, query.redirect_uri); + oauthClientService.validateResponseType(client, query.response_type); + + const requestedScopes = query.scope + ? query.scope.split(' ').filter((scope) => scope.length > 0) + : []; + oauthClientService.validateScopes(client, requestedScopes); + + return c.json( + { + client: { + id: client.id, + clientId: client.clientId, + name: client.name, + }, + redirect_uri: query.redirect_uri, + redirect_origin: new URL(query.redirect_uri).origin, + scopes: parseScopesWithDescriptions(query.scope), + }, + 200, + ); + }, +); diff --git a/packages/server/src/routes/api/oauth/index.ts b/packages/server/src/routes/api/oauth/index.ts index d9d35ddd..07e4148e 100644 --- a/packages/server/src/routes/api/oauth/index.ts +++ b/packages/server/src/routes/api/oauth/index.ts @@ -4,8 +4,10 @@ import { oauthProviderAuthorizeGet } from './_provider/authorize/get.ts'; import { oauthProviderCallbackGet } from './_provider/callback/get.ts'; import { oauthProviderCallbackPost } from './_provider/callback/post.ts'; import { oauthProviderDelete } from './_provider/delete.ts'; +import { authorizationContextGet } from './authorization-context/get.ts'; export const oauthRoutes = new Hono() + .route('/', authorizationContextGet) .route('/', oauthProviderAuthorizeGet) .route('/', oauthProviderCallbackGet) .route('/', oauthProviderCallbackPost) diff --git a/packages/server/src/schemas/response.ts b/packages/server/src/schemas/response.ts index 81ec2c9d..eadbd2cb 100644 --- a/packages/server/src/schemas/response.ts +++ b/packages/server/src/schemas/response.ts @@ -114,6 +114,19 @@ const ConsentUser = z }) .describe('Consent User'); +const AuthorizationContextResponse = z + .object({ + client: ConsentClient, + redirect_uri: f.redirectUri.describe('Validated OAuth redirect URI'), + redirect_origin: z + .url() + .describe('Origin extracted from the validated redirect URI'), + scopes: z + .array(ConsentScope) + .describe('Validated requested OAuth scopes with descriptions'), + }) + .describe('Authorization context shown before authentication'); + const LinkedOAuthAccount = z .object({ provider_name: f.providerName.describe('OAuth provider identifier'), @@ -388,6 +401,7 @@ export const r = { ConsentClient, ConsentScope, ConsentUser, + AuthorizationContextResponse, LinkedOAuthAccount, AvailableOAuthProvider, OAuthAuthenticationMethod,