diff --git a/web/e2e/auth-entry.spec.ts b/web/e2e/auth-entry.spec.ts index aeeec995..e54def22 100644 --- a/web/e2e/auth-entry.spec.ts +++ b/web/e2e/auth-entry.spec.ts @@ -1,15 +1,24 @@ +import { randomBytes } from 'node:crypto' import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -test.describe('Auth Entry (Real API)', () => { +test.describe('Auth Entry', () => { test.beforeEach(async ({ page }) => { await setEnglishLocale(page) }) test('validates required fields and preserves returnTo on register link', async ({ page }) => { + await page.route('**/runtime-config.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__SKILLHUB_RUNTIME_CONFIG__ = { authDirectEnabled: "true", authDirectProvider: "local" }', + }) + }) await page.goto('/login?returnTo=%2Fdashboard%2Ftokens') await expect(page.getByRole('heading', { name: 'Login to SkillHub' })).toBeVisible() + await expect(page.getByText(/password compatibility layer/i)).toHaveCount(0) await page.getByRole('button', { name: 'Login' }).click() await expect(page.getByText('Username is required')).toBeVisible() @@ -17,5 +26,252 @@ test.describe('Auth Entry (Real API)', () => { await page.getByRole('link', { name: 'Sign up now' }).click() await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens') + + await page.getByLabel('Email').fill('test@example.com') + await page.getByRole('button', { name: 'Register & Login' }).click() + await expect(page.getByText('Username is required')).toBeVisible() + await expect(page.getByText('Password is required')).toBeVisible() + + await page.getByLabel('Email').fill('') + await page.getByLabel('Email').blur() + await expect(page.getByText('Email is required')).toBeVisible() + }) + + test('shows configured OAuth methods without exposing unsupported organization discovery', async ({ page }) => { + await page.route('**/api/v1/auth/methods*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'ok', + data: [ + { + id: 'github', + methodType: 'OAUTH_REDIRECT', + provider: 'github', + displayName: 'GitHub', + actionUrl: '/oauth2/authorization/github', + }, + { + id: 'enterprise-discovery', + methodType: 'ENTERPRISE_DISCOVERY', + provider: 'enterprise', + displayName: 'Organization login', + actionUrl: '/api/v1/auth/login-discovery', + }, + ], + }), + }) + }) + + await page.goto('/login') + + await expect(page.getByRole('button', { name: 'GitHub' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Organization login' })).toHaveCount(0) + }) + + test('keeps registration usable without configured OAuth methods', async ({ page }) => { + await page.route('**/api/v1/auth/methods*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, msg: 'ok', data: [] }), + }) + }) + + await page.goto('/register') + + await expect(page.getByRole('button', { name: 'Register & Login' })).toBeVisible() + await expect(page.getByText('Sign in directly with your existing OAuth account')).toHaveCount(0) + }) + + test('keeps the login form available when the session status check fails', async ({ page }) => { + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ status: 503, contentType: 'application/json', body: '{"code":503,"msg":"Unavailable"}' }) + }) + + await page.goto('/login') + + await expect(page.getByLabel('Username')).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible() + + await page.goto('/dashboard/tokens') + await expect(page.getByRole('heading', { name: 'Something went wrong' })).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0) + }) + + test('routes configured direct password login to the direct endpoint', async ({ page }) => { + let directRequests = 0 + let localRequests = 0 + await page.route('**/runtime-config.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__SKILLHUB_RUNTIME_CONFIG__ = { authDirectEnabled: "true", authDirectProvider: "local" }', + }) + }) + await page.route('**/api/v1/auth/direct/login', async (route) => { + directRequests += 1 + expect(route.request().method()).toBe('POST') + expect(route.request().postDataJSON()).toEqual({ provider: 'local', username: 'direct-demo', password: 'Valid1!pass' }) + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, msg: 'ok', data: { userId: 'direct-demo', username: 'direct-demo', displayName: 'Direct Demo', platformRoles: [] } }), + }) + }) + await page.route('**/api/v1/auth/local/login', async (route) => { + localRequests += 1 + await route.abort() + }) + + await page.goto('/login') + await page.getByLabel('Username').fill('direct-demo') + await page.getByLabel('Password', { exact: true }).fill('Valid1!pass') + await page.getByRole('button', { name: 'Login' }).click() + + await expect(page).toHaveURL('/') + expect(directRequests).toBe(1) + expect(localRequests).toBe(0) + }) + + test('keeps the desktop layout stable across theme and login-mode changes', async ({ page }, testInfo) => { + await page.route('**/runtime-config.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__SKILLHUB_RUNTIME_CONFIG__ = { authSessionBootstrapEnabled: "true", authSessionBootstrapProvider: "proxy", authSessionBootstrapAuto: "false" }', + }) + }) + + for (const viewport of [{ width: 1280, height: 800 }, { width: 1920, height: 1080 }]) { + await page.setViewportSize(viewport) + await page.goto('/login') + await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }) + const title = page.getByRole('heading', { name: 'Login to SkillHub' }) + const registerLink = page.getByRole('link', { name: 'Sign up now' }) + await expect(title).toBeVisible() + await expect(registerLink).toBeVisible() + + const before = await title.boundingBox() + await page.getByRole('button', { name: 'Organization login' }).click() + await expect(page.getByRole('button', { name: 'Log in with work account' })).toBeVisible() + const after = await title.boundingBox() + expect(before && after && Math.abs(before.y - after.y)).toBeLessThan(2) + await expect(registerLink).toBeVisible() + await page.getByRole('button', { name: 'Personal login' }).click() + await expect(page.getByLabel('Password', { exact: true })).toBeVisible() + + const themeSwitch = page.getByRole('switch', { name: 'Dark theme' }) + if (await themeSwitch.getAttribute('aria-checked') === 'true') await themeSwitch.click() + const lightArtwork = page.locator('img[src$="/login-skill-art-light.png"]') + const darkArtwork = page.locator('img[src$="/login-skill-art-dark.png"]') + await expect(lightArtwork).toBeVisible() + await expect.poll(() => lightArtwork.evaluate((image) => (image as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await expect(page.locator('img[src$="/login-skill-art-dark.png"]')).toBeHidden() + await page.screenshot({ path: testInfo.outputPath(`login-${viewport.width}-light.png`) }) + await themeSwitch.click() + await expect(darkArtwork).toBeVisible() + await expect.poll(() => darkArtwork.evaluate((image) => (image as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await expect(page.locator('img[src$="/login-skill-art-light.png"]')).toBeHidden() + await page.screenshot({ path: testInfo.outputPath(`login-${viewport.width}-dark.png`) }) + + const dimensions = await page.evaluate(() => ({ + scrollHeight: document.documentElement.scrollHeight, + scrollWidth: document.documentElement.scrollWidth, + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + })) + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.innerWidth + 2) + expect(dimensions.scrollHeight).toBeLessThanOrEqual(dimensions.innerHeight + 2) + + await page.goto('/register') + await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }) + const registerButton = page.getByRole('button', { name: 'Register & Login' }) + await expect(registerButton).toBeVisible() + const registerButtonBox = await registerButton.boundingBox() + expect(registerButtonBox && registerButtonBox.y + registerButtonBox.height).toBeLessThanOrEqual(viewport.height + 2) + const registerDimensions = await page.evaluate(() => ({ + scrollHeight: document.documentElement.scrollHeight, + scrollWidth: document.documentElement.scrollWidth, + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + })) + expect(registerDimensions.scrollWidth).toBeLessThanOrEqual(registerDimensions.innerWidth + 2) + expect(registerDimensions.scrollHeight).toBeLessThanOrEqual(registerDimensions.innerHeight + 2) + await page.screenshot({ path: testInfo.outputPath(`register-${viewport.width}-dark.png`) }) + } + + await page.getByRole('button', { name: 'English' }).click() + await page.getByRole('menuitem', { name: '中文' }).click() + await expect(page.getByText(/让技能\s*连接团队/)).toBeVisible() + await page.getByRole('button', { name: '中文' }).click() + await page.getByRole('menuitem', { name: 'Русский' }).click() + await expect(page.getByText(/Навыки\s*объединяют\s*команду/)).toBeVisible() + }) + + test('keeps configured session bootstrap available in the organization view', async ({ page }) => { + await page.route('**/runtime-config.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.__SKILLHUB_RUNTIME_CONFIG__ = { authSessionBootstrapEnabled: "true", authSessionBootstrapProvider: "proxy", authSessionBootstrapAuto: "false" }', + }) + }) + + await page.goto('/login') + await page.getByRole('button', { name: 'Organization login' }).click() + + await expect(page.getByRole('button', { name: 'Log in with work account' })).toBeVisible() + await expect(page.getByLabel('Password', { exact: true })).toBeHidden() + + await page.getByRole('button', { name: 'Personal login' }).click() + await expect(page.getByLabel('Password', { exact: true })).toBeVisible() + }) + + test('logs in to an explicit local destination on the isolated runtime', async ({ browser, page }) => { + test.skip(process.env.SKILLHUB_RUNTIME_E2E !== '1', 'Requires an isolated runtime with local registration enabled') + test.setTimeout(90_000) + + const suffix = randomBytes(6).toString('hex') + const username = `pr901_${suffix}` + const password = `${randomBytes(18).toString('base64url')}Aa1!` + + await page.goto('/register') + await page.getByLabel('Username').fill(username) + await page.getByLabel('Email').fill(`${username}@example.com`) + await page.getByLabel('Password').fill(password) + await page.getByRole('button', { name: 'Register & Login' }).click() + await expect(page).toHaveURL('/') + + await page.goto('/login') + await expect(page).toHaveURL('/') + await page.goto('/login?returnTo=%2Fdashboard%2Ftokens%3Ftab%3Dactive%23latest') + await expect(page).toHaveURL('/dashboard/tokens?tab=active#latest') + await page.goto('/login?returnTo=%2Flogin') + await expect(page).toHaveURL('/') + + const loginPage = await browser.newPage() + await setEnglishLocale(loginPage) + await loginPage.goto('/login?returnTo=%2Fdashboard%2Ftokens') + await expect.poll(() => loginPage.evaluate(async () => (await fetch('/api/v1/auth/me')).status)).toBe(401) + + await loginPage.getByLabel('Username').fill(username) + await loginPage.getByLabel('Password', { exact: true }).fill(password) + await loginPage.getByRole('button', { name: 'Login' }).click() + await expect(loginPage).toHaveURL('/dashboard/tokens') + await expect.poll(() => loginPage.evaluate(async () => (await fetch('/api/v1/auth/me')).status)).toBe(200) + await loginPage.close() + + const defaultLoginPage = await browser.newPage() + await setEnglishLocale(defaultLoginPage) + await defaultLoginPage.goto('/login') + await defaultLoginPage.getByLabel('Username').fill(username) + await defaultLoginPage.getByLabel('Password', { exact: true }).fill(password) + await defaultLoginPage.getByRole('button', { name: 'Login' }).click() + await expect(defaultLoginPage).toHaveURL('/') + await defaultLoginPage.close() }) }) diff --git a/web/e2e/register-email-required.spec.ts b/web/e2e/register-email-required.spec.ts index 8bdefefa..dea9f6d1 100644 --- a/web/e2e/register-email-required.spec.ts +++ b/web/e2e/register-email-required.spec.ts @@ -26,7 +26,9 @@ test.describe('Register Email Required (Real API)', () => { await page.getByLabel('Password').fill(user.password) await page.getByRole('button', { name: 'Register & Login' }).click() - await expect(page).toHaveURL('/dashboard') + await expect(page).toHaveURL('/') + const sessionResponse = await page.request.get('/api/v1/auth/me') + expect(sessionResponse.status()).toBe(200) }) test('shows required validation when email is missing', async ({ page }) => { diff --git a/web/public/login-skill-art-dark.png b/web/public/login-skill-art-dark.png new file mode 100644 index 00000000..620cc36b Binary files /dev/null and b/web/public/login-skill-art-dark.png differ diff --git a/web/public/login-skill-art-light.png b/web/public/login-skill-art-light.png new file mode 100644 index 00000000..65ba6a75 Binary files /dev/null and b/web/public/login-skill-art-light.png differ diff --git a/web/src/app/layout-main-content.test.ts b/web/src/app/layout-main-content.test.ts index 3a0e6f8b..6ef027d4 100644 --- a/web/src/app/layout-main-content.test.ts +++ b/web/src/app/layout-main-content.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { + AUTH_MAIN_CLASS_NAME, CENTERED_DASHBOARD_CONTENT_CLASS_NAME, CENTERED_MAIN_CLASS_NAME, CENTERED_SEARCH_CONTENT_CLASS_NAME, DEFAULT_MAIN_CLASS_NAME, + SPLIT_AUTH_MAIN_CLASS_NAME, getAppMainContentLayout, resolveAppMainContentPathname, } from './layout-main-content' @@ -36,6 +38,24 @@ describe('getAppMainContentLayout', () => { expect(layout.contentClassName).toContain('max-w-[1100px]') }) + it('uses the full-bleed split layout for login and registration', () => { + for (const pathname of ['/login', '/register']) { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: SPLIT_AUTH_MAIN_CLASS_NAME, + contentClassName: '', + }) + } + }) + + it('keeps the compact entry layout for password reset', () => { + for (const pathname of ['/reset-password']) { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: AUTH_MAIN_CLASS_NAME, + contentClassName: '', + }) + } + }) + it('leaves other non-landing routes on the default full-width app content layout', () => { expect(getAppMainContentLayout('/space/acme/demo')).toEqual({ mainClassName: DEFAULT_MAIN_CLASS_NAME, diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts index e6ad95f4..e6205406 100644 --- a/web/src/app/layout-main-content.ts +++ b/web/src/app/layout-main-content.ts @@ -1,10 +1,14 @@ export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10' +export const SPLIT_AUTH_MAIN_CLASS_NAME = 'flex-1 relative z-10' +export const AUTH_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 pb-4 sm:px-6 md:px-8' export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10' export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]' export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const +export const AUTH_ENTRY_PATHS = ['/login', '/register', '/reset-password'] as const +export const SPLIT_AUTH_PATHS = ['/login', '/register'] as const interface AppMainContentLayout { mainClassName: string @@ -26,6 +30,20 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout } } + if (SPLIT_AUTH_PATHS.includes(pathname as typeof SPLIT_AUTH_PATHS[number])) { + return { + mainClassName: SPLIT_AUTH_MAIN_CLASS_NAME, + contentClassName: '', + } + } + + if (AUTH_ENTRY_PATHS.includes(pathname as typeof AUTH_ENTRY_PATHS[number])) { + return { + mainClassName: AUTH_MAIN_CLASS_NAME, + contentClassName: '', + } + } + if (pathname === '/search') { return { mainClassName: CENTERED_MAIN_CLASS_NAME, diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 164582d2..1981c41a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -13,7 +13,7 @@ import { syncDocumentLanguage } from '@/shared/lib/document-language' import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { getAppHeaderClassName } from './layout-header-style' -import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' +import { AUTH_ENTRY_PATHS, SPLIT_AUTH_PATHS, getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none' @@ -37,6 +37,8 @@ export function Layout() { const previousPathnameRef = useRef(pathname) const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + const isAuthEntryRoute = AUTH_ENTRY_PATHS.includes(contentLayoutPathname as typeof AUTH_ENTRY_PATHS[number]) + const isSplitAuthRoute = SPLIT_AUTH_PATHS.includes(contentLayoutPathname as typeof SPLIT_AUTH_PATHS[number]) const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard') const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/') const governanceVisible = canViewGovernanceCenter(user?.platformRoles) @@ -113,63 +115,72 @@ export function Layout() { {/* Header */} -
- - SkillHub - +
+ {!isSplitAuthRoute ? ( + + SkillHub + + ) : null} {/* Desktop nav — lg+ only */} - + ) : null}
{/* Hamburger — visible below lg */} - + {!isAuthEntryRoute ? ( + + ) : null} {user && } {isLoading ? null : user ? ( - ) : ( + ) : !isAuthEntryRoute ? ( {t('nav.login')} - )} + ) : null}
{/* Mobile nav dropdown */} - {mobileMenuOpen ? ( + {mobileMenuOpen && !isAuthEntryRoute ? (
) } diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d20ea269..489efd73 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -4,7 +4,7 @@ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' import { RouteError } from '@/shared/components/route-error' -import { createRequireAuth } from '@/shared/lib/auth-route' +import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } from '@/shared/lib/auth-route' import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery' import { normalizeSearchQuery } from '@/shared/lib/search-query' @@ -228,6 +228,7 @@ const rootRoute = createRootRoute({ }) const requireAuth = createRequireAuth(getCurrentUser) +const redirectAuthenticated = createRedirectAuthenticated(getCurrentUser) const landingRoute = createRoute({ getParentRoute: () => rootRoute, @@ -244,8 +245,9 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', + beforeLoad: redirectAuthenticated, validateSearch: (search: Record): { returnTo?: string; reason?: string } => ({ - returnTo: typeof search.returnTo === 'string' && search.returnTo ? search.returnTo : undefined, + returnTo: isSafeAuthReturnTo(search.returnTo) ? search.returnTo : undefined, reason: typeof search.reason === 'string' ? search.reason : undefined, }), component: LoginPage, @@ -255,7 +257,7 @@ const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', validateSearch: (search: Record) => ({ - returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + returnTo: isSafeAuthReturnTo(search.returnTo) ? search.returnTo : '', }), component: RegisterPage, }) diff --git a/web/src/features/auth/auth-shell.test.tsx b/web/src/features/auth/auth-shell.test.tsx new file mode 100644 index 00000000..f8d92c6d --- /dev/null +++ b/web/src/features/auth/auth-shell.test.tsx @@ -0,0 +1,20 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { AuthShell } from './auth-shell' + +describe('AuthShell', () => { + it('renders theme-aware product content beside the form', () => { + const html = renderToStaticMarkup(
Login form
) + + expect(html).toContain('dark:bg-slate-950') + expect(html).toContain('sticky top-0 hidden h-svh') + expect(html).toContain('authShell.heroTitle') + expect(html).toContain('authShell.shortTagline') + expect(html).toContain('login-skill-art-light.png') + expect(html).toContain('login-skill-art-dark.png') + expect(html).toContain('authShell.backHome') + expect(html).toContain('xl:hidden') + expect(html).toContain('pt-20') + expect(html).toContain('Login form') + }) +}) diff --git a/web/src/features/auth/auth-shell.tsx b/web/src/features/auth/auth-shell.tsx new file mode 100644 index 00000000..1c1aea75 --- /dev/null +++ b/web/src/features/auth/auth-shell.tsx @@ -0,0 +1,62 @@ +import type React from 'react' +import { ArrowLeft } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { BrandMark } from '@/shared/components/brand-mark' +import { withBasePath } from '@/shared/lib/base-path' +import { cn } from '@/shared/lib/utils' + +interface AuthShellProps { + children: React.ReactNode + className?: string +} + +/** Shared theme-aware product introduction and form layout for login and registration. */ +export function AuthShell({ children, className }: AuthShellProps) { + const { t } = useTranslation() + + return ( +
+ + +
+ + +
+
+ {children} +
+
+
+
+ ) +} diff --git a/web/src/features/auth/login-button.test.tsx b/web/src/features/auth/login-button.test.tsx new file mode 100644 index 00000000..a78d0673 --- /dev/null +++ b/web/src/features/auth/login-button.test.tsx @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AuthMethod } from '@/api/types' +import { AuthMethodButtonList } from './login-button' + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string, values?: Record) => ( + key === 'loginButton.loginWith' && values?.name ? `Login with ${values.name}` : key + ), + }), + } +}) + +vi.mock('@/shared/lib/base-path', () => ({ + BASE_PATH: '/', + withBasePath: (path: string) => path, +})) + +vi.mock('./use-auth-methods', () => ({ + useAuthMethods: () => ({ data: [], isLoading: false }), +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})) + +const methods: AuthMethod[] = [ + { + id: 'github', + methodType: 'OAUTH_REDIRECT', + provider: 'github', + displayName: 'GitHub', + actionUrl: '/oauth2/authorization/github', + }, + { + id: 'feishu', + methodType: 'OAUTH_REDIRECT', + provider: 'feishu', + displayName: 'Feishu', + actionUrl: '/oauth2/authorization/feishu', + }, + { + id: 'password', + methodType: 'PASSWORD', + provider: 'local', + displayName: 'Password', + actionUrl: '/login', + }, +] + +describe('AuthMethodButtonList', () => { + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('renders OAuth redirect methods from backend configuration only', () => { + render() + + expect(screen.getByText('Login with GitHub')).toBeTruthy() + expect(screen.getByText('Login with Feishu')).toBeTruthy() + expect(screen.queryByText('Login with Password')).toBeNull() + }) + + it('renders the supplied empty fallback when no OAuth methods are available', () => { + render( method.methodType !== 'OAUTH_REDIRECT')} emptyFallback={

No methods

} />) + + expect(screen.getByText('No methods')).toBeTruthy() + }) + + it('navigates to the configured OAuth action URL', () => { + vi.stubGlobal('location', { href: 'http://localhost/login' }) + + render() + fireEvent.click(screen.getByText('Login with GitHub')) + + expect(window.location.href).toBe('/oauth2/authorization/github') + }) +}) diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index a452a3a5..08d9b656 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -1,62 +1,99 @@ import { useTranslation } from 'react-i18next' +import type React from 'react' import { Button } from '@/shared/ui/button' import { withBasePath } from '@/shared/lib/base-path' +import { cn } from '@/shared/lib/utils' +import type { AuthMethod } from '@/api/types' import { useAuthMethods } from './use-auth-methods' interface LoginButtonProps { returnTo?: string + compact?: boolean + emptyFallback?: React.ReactNode } /** * Returns the appropriate icon for a given OAuth provider. */ -function OAuthIcon({ provider }: { provider: string }) { +function OAuthIcon({ provider, compact = false }: { provider: string, compact?: boolean }) { const normalizedProvider = provider.toLowerCase() return ( {provider} ) } -/** - * Renders OAuth login buttons from the auth-method catalog returned by the backend. - */ -export function LoginButton({ returnTo }: LoginButtonProps) { - const { t } = useTranslation() - const { data, isLoading } = useAuthMethods(returnTo) +interface AuthMethodButtonListProps { + methods?: AuthMethod[] + isLoading?: boolean + compact?: boolean + emptyFallback?: React.ReactNode +} - const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') +/** + * Renders OAuth redirect entries advertised by the backend. + */ +export function AuthMethodButtonList({ + methods, + isLoading = false, + compact = false, + emptyFallback = null, +}: AuthMethodButtonListProps) { + const { t } = useTranslation() + const providers = (methods ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT') if (isLoading) { return ( -
-
) } + if (providers.length === 0) { + return <>{emptyFallback} + } + return ( -
+
{providers.map((provider) => ( ))}
) } +/** + * Renders OAuth login buttons from the auth-method catalog returned by the backend. + */ +export function LoginButton({ returnTo, compact = false, emptyFallback }: LoginButtonProps) { + const { data, isLoading } = useAuthMethods(returnTo) + return ( + + ) +} diff --git a/web/src/features/auth/session-bootstrap-entry.tsx b/web/src/features/auth/session-bootstrap-entry.tsx index ed904dc2..bcc0054f 100644 --- a/web/src/features/auth/session-bootstrap-entry.tsx +++ b/web/src/features/auth/session-bootstrap-entry.tsx @@ -7,13 +7,14 @@ import { useSessionBootstrap } from './use-session-bootstrap' interface SessionBootstrapEntryProps { onAuthenticated: () => Promise methodDisplayName?: string + compact?: boolean } /** * Optional login entry that attempts to bootstrap a browser session from an upstream enterprise * identity before showing manual login choices. */ -export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: SessionBootstrapEntryProps) { +export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName, compact = false }: SessionBootstrapEntryProps) { const { t } = useTranslation() const config = getSessionBootstrapRuntimeConfig() const bootstrapMutation = useSessionBootstrap() @@ -47,6 +48,36 @@ export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName }: Se ? bootstrapMutation.error.message : null + if (compact) { + return ( +
+ + + {manualError ? ( +

{manualError}

+ ) : null} +
+ ) + } + return (
diff --git a/web/src/features/auth/use-auth-methods.test.ts b/web/src/features/auth/use-auth-methods.test.ts index f353b95c..7e15e10e 100644 --- a/web/src/features/auth/use-auth-methods.test.ts +++ b/web/src/features/auth/use-auth-methods.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getAuthMethodsQueryOptions, useAuthMethods } from './use-auth-methods' +const getMethods = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/client', () => ({ authApi: { getMethods } })) + describe('getAuthMethodsQueryOptions', () => { it('keeps login auth-method lookup local to the page and bypasses the global 401 redirect', () => { const options = getAuthMethodsQueryOptions('/dashboard') @@ -10,6 +14,12 @@ describe('getAuthMethodsQueryOptions', () => { expect(options.meta).toEqual({ skipGlobalErrorHandler: true }) expect(options.queryFn).toBeTypeOf('function') }) + + it('does not invent provider buttons when the server is unavailable', async () => { + getMethods.mockRejectedValueOnce(new Error('backend unavailable')) + + await expect(getAuthMethodsQueryOptions().queryFn()).rejects.toThrow('backend unavailable') + }) }) describe('use-auth-methods module exports', () => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3e4333f4..4c25114a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -329,7 +329,21 @@ }, "login": { "title": "Login to SkillHub", - "subtitle": "Choose a method to continue", + "subtitle": "One account for better skill collaboration", + "eyebrow": "Welcome back", + "tabEnterprise": "Organization login", + "tabPersonal": "Personal login", + "loginMode": "Login method", + "enterpriseTitle": "Enterprise SSO", + "enterpriseHint": "When administrators configure a work login entry, you can continue with your enterprise identity.", + "enterpriseConfigHint": "Work login entries are enabled by administrators.", + "noEnterpriseMethods": "No work account login is available yet. Use a personal account for now, or contact your administrator.", + "personalTitle": "Password login", + "personalHint": "Use your SkillHub username and password.", + "otherMethods": "Or continue with", + "orgLoginHint": "For organizations with single sign-on configured", + "externalIdentityMethods": "External identity sources", + "externalIdentityHint": "Feishu, DingTalk, GitHub, GitLab, and similar entries are advertised by the server. Account ownership is decided by backend binding and organization membership.", "tabPassword": "Password", "tabOAuth": "OAuth", "username": "Username", @@ -346,12 +360,13 @@ "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.", - "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.", "enterpriseSsoTitle": "Enterprise SSO", "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.", "enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.", "enterpriseSsoAction": "Try {{name}}", "enterpriseSsoSubmitting": "Trying {{name}}...", + "enterpriseContinue": "Log in with work account", + "switchToPersonal": "Use personal account instead", "agreementPrefix": "By logging in, you agree to our", "terms": "Terms of Service", "and": "and", @@ -360,6 +375,7 @@ "register": { "title": "Create Account", "subtitle": "Register locally or sign in directly with OAuth.", + "eyebrow": "Create a personal account", "tabLocal": "Local Account", "tabOAuth": "OAuth", "username": "Username", @@ -1478,8 +1494,36 @@ }, "loginButton": { "loading": "Loading...", + "loadingShort": "Loading", "loginWith": "Login with {{name}}" }, + "authShell": { + "eyebrow": "SkillHub", + "backHome": "Back to home", + "heroTitle": "Discover, preserve, and reuse\nyour team's skills", + "heroSubtitle": "Start with a SKILL.md.\nTurn experience into a capability your team can share.", + "heroFooter": "Bring team knowledge together and unlock skill value", + "shortTagline": "Skills\nBuild\nTogether", + "capabilities": "What you can do with SkillHub", + "discoverTitle": "Discover skills", + "discoverDescription": "Find reusable skills and practices across your team.", + "shareTitle": "Share knowledge", + "shareDescription": "Publish versions and keep experience moving.", + "governTitle": "Govern safely", + "governDescription": "Collaborate and manage access with clear permissions.", + "previewNavLibrary": "Library", + "previewNavMine": "My skills", + "previewNavTeam": "Team space", + "previewSearch": "Search skills, keywords, or teams", + "previewChipAll": "All", + "previewChipProduct": "Product", + "previewChipData": "Data", + "previewChipCommon": "General", + "previewRowProduct": "Product", + "previewRowData": "Data", + "previewRowDev": "Dev", + "previewRowCommon": "General" + }, "ratingInput": { "yourRating": "Your rating: {{score}} stars" }, diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f8b28c2f..fc0688d7 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -329,7 +329,21 @@ }, "login": { "title": "Вход в SkillHub", - "subtitle": "Выберите способ продолжения", + "subtitle": "Один аккаунт для удобной совместной работы с навыками", + "eyebrow": "С возвращением", + "tabEnterprise": "Вход через организацию", + "tabPersonal": "Личный вход", + "loginMode": "Способ входа", + "enterpriseTitle": "Корпоративный SSO", + "enterpriseHint": "Если администраторы настроили рабочий вход, можно продолжить с корпоративной идентичностью.", + "enterpriseConfigHint": "Рабочие способы входа включает администратор.", + "noEnterpriseMethods": "Рабочий вход пока недоступен. Используйте личный аккаунт или обратитесь к администратору.", + "personalTitle": "Вход по паролю", + "personalHint": "Используйте имя пользователя и пароль SkillHub.", + "otherMethods": "Или войдите через", + "orgLoginHint": "Для организаций с настроенным единым входом", + "externalIdentityMethods": "Внешние источники идентификации", + "externalIdentityHint": "Feishu, DingTalk, GitHub, GitLab и похожие входы публикуются сервером. Принадлежность аккаунта определяют привязки и членство в организациях на backend.", "tabPassword": "Пароль", "tabOAuth": "OAuth", "username": "Имя пользователя", @@ -346,12 +360,13 @@ "noAccount": "Нет аккаунта?", "register": "Зарегистрироваться", "oauthHint": "После аутентификации OAuth вы будете автоматически перенаправлены обратно на этот сайт.", - "passwordCompatHint": "В этом развёртывании включён слой совместимости паролей. Форма направит запрос в {{name}} вместо фиксированной локальной учётной записи.", "enterpriseSsoTitle": "Корпоративный SSO", "enterpriseSsoHint": "В этом развёртывании включён слой совместимости. Если в браузере уже есть сессия {{name}}, можно попробовать сразу установить сессию SkillHub.", "enterpriseSsoAutoHint": "В этом развёртывании включено автоматическое зондирование {{name}}. Если оно не сработает, продолжайте стандартными способами входа.", "enterpriseSsoAction": "Попробовать {{name}}", "enterpriseSsoSubmitting": "Пробуем {{name}}...", + "enterpriseContinue": "Войти с рабочим аккаунтом", + "switchToPersonal": "Использовать личный аккаунт", "agreementPrefix": "Входя в систему, вы соглашаетесь с", "terms": "Условиями использования", "and": "и", @@ -360,6 +375,7 @@ "register": { "title": "Создать аккаунт", "subtitle": "Зарегистрируйтесь локально или войдите через OAuth.", + "eyebrow": "Создать личный аккаунт", "tabLocal": "Локальный аккаунт", "tabOAuth": "OAuth", "username": "Имя пользователя", @@ -1589,8 +1605,36 @@ }, "loginButton": { "loading": "Загрузка...", + "loadingShort": "Загрузка", "loginWith": "Войти через {{name}}" }, + "authShell": { + "eyebrow": "SkillHub", + "backHome": "На главную", + "heroTitle": "Открывайте и сохраняйте\nнавыки вашей команды", + "heroSubtitle": "Начните с SKILL.md.\nПревратите опыт в доступный команде навык.", + "heroFooter": "Собирайте знания команды и раскрывайте ценность навыков", + "shortTagline": "Навыки\nобъединяют\nкоманду", + "capabilities": "Возможности SkillHub", + "discoverTitle": "Поиск навыков", + "discoverDescription": "Находите навыки и практики, которые команда может использовать повторно.", + "shareTitle": "Обмен знаниями", + "shareDescription": "Публикуйте версии и передавайте опыт дальше.", + "governTitle": "Безопасное управление", + "governDescription": "Работайте вместе с понятными правами доступа.", + "previewNavLibrary": "Библиотека", + "previewNavMine": "Мои навыки", + "previewNavTeam": "Команда", + "previewSearch": "Поиск навыков, ключевых слов или команд", + "previewChipAll": "Все", + "previewChipProduct": "Продукт", + "previewChipData": "Данные", + "previewChipCommon": "Общие", + "previewRowProduct": "Продукт", + "previewRowData": "Данные", + "previewRowDev": "Разработка", + "previewRowCommon": "Общие" + }, "namespaceStatus": { "active": "Активно", "frozen": "Заморожено", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d0021770..f900f983 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -329,7 +329,21 @@ }, "login": { "title": "登录 SkillHub", - "subtitle": "选择一个方式登录以继续", + "subtitle": "一个账号,开启更高效的技能协作", + "eyebrow": "欢迎回来", + "tabEnterprise": "组织登录", + "tabPersonal": "个人登录", + "loginMode": "登录方式", + "enterpriseTitle": "企业单点登录", + "enterpriseHint": "当管理员配置了企业登录入口时,可直接使用企业身份继续。", + "enterpriseConfigHint": "企业登录入口由管理员开通。", + "noEnterpriseMethods": "当前还没有可用的企业登录入口。你可以先使用个人账号登录,或联系管理员开通。", + "personalTitle": "账号密码登录", + "personalHint": "使用 SkillHub 用户名和密码登录。", + "otherMethods": "或使用以下方式登录", + "orgLoginHint": "适用于已配置企业单点登录的组织", + "externalIdentityMethods": "外部身份源", + "externalIdentityHint": "飞书、钉钉、GitHub、GitLab 等入口由服务端配置;登录后的账号归属由后端绑定和组织关系决定。", "tabPassword": "账号密码", "tabOAuth": "OAuth", "username": "用户名", @@ -346,12 +360,13 @@ "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。", - "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。", "enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。", "enterpriseSsoAction": "尝试 {{name}} 登录", "enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...", + "enterpriseContinue": "通过企业账号登录", + "switchToPersonal": "改用个人账号", "agreementPrefix": "登录即表示你同意我们的", "terms": "服务条款", "and": "和", @@ -360,6 +375,7 @@ "register": { "title": "创建账号", "subtitle": "支持本地注册,也可以直接使用 OAuth 登录进入平台。", + "eyebrow": "创建个人账号", "tabLocal": "本地账号", "tabOAuth": "OAuth", "username": "用户名", @@ -1478,8 +1494,36 @@ }, "loginButton": { "loading": "加载中...", + "loadingShort": "加载中", "loginWith": "使用 {{name}} 登录" }, + "authShell": { + "eyebrow": "SkillHub", + "backHome": "返回首页", + "heroTitle": "让团队的技能\n被发现、被沉淀、被复用", + "heroSubtitle": "从一份 SKILL.md 开始,\n把经验转化为可共享的团队能力。", + "heroFooter": "汇聚团队智慧,释放技能价值", + "shortTagline": "让技能\n连接团队", + "capabilities": "SkillHub 的能力", + "discoverTitle": "发现技能", + "discoverDescription": "找到团队可复用的技能包与实践。", + "shareTitle": "沉淀共享", + "shareDescription": "发布版本,让经验持续流转。", + "governTitle": "安全治理", + "governDescription": "在清晰的权限下协作与管理。", + "previewNavLibrary": "技能库", + "previewNavMine": "我的技能", + "previewNavTeam": "团队空间", + "previewSearch": "搜索技能、关键词或团队", + "previewChipAll": "全部", + "previewChipProduct": "产品团队", + "previewChipData": "数据团队", + "previewChipCommon": "通用", + "previewRowProduct": "产品", + "previewRowData": "数据", + "previewRowDev": "研发", + "previewRowCommon": "通用" + }, "ratingInput": { "yourRating": "你的评分: {{score}} 星" }, diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx index dacfa477..2c35c14a 100644 --- a/web/src/pages/login.test.tsx +++ b/web/src/pages/login.test.tsx @@ -1,9 +1,22 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach } from 'vitest' import { describe, expect, it, vi } from 'vitest' +const authMethodsFixture = vi.hoisted(() => ({ + methods: [] as Array<{ id: string, methodType: string }>, + bootstrapEnabled: false, + directEnabled: false, + isError: false, + returnTo: '', + navigate: vi.fn(), + mutateAsync: vi.fn(), +})) + vi.mock('@tanstack/react-router', () => ({ Link: ({ children }: { children: unknown }) => children, - useNavigate: () => vi.fn(), - useSearch: () => ({ returnTo: '' }), + useNavigate: () => authMethodsFixture.navigate, + useSearch: () => ({ returnTo: authMethodsFixture.returnTo }), })) vi.mock('react-i18next', async () => { @@ -18,53 +31,66 @@ vi.mock('react-i18next', async () => { }) vi.mock('lucide-react', () => ({ + ArrowRight: () => null, Eye: () => null, EyeOff: () => null, + LockKeyhole: () => null, + UserRound: () => null, })) vi.mock('@/api/client', () => ({ - getDirectAuthRuntimeConfig: () => ({ enabled: false }), + getDirectAuthRuntimeConfig: () => ({ enabled: authMethodsFixture.directEnabled, provider: 'local' }), + getSessionBootstrapRuntimeConfig: () => ({ enabled: authMethodsFixture.bootstrapEnabled, provider: 'proxy' }), +})) + +vi.mock('@/features/auth/auth-shell', () => ({ + AuthShell: ({ children }: { children: unknown }) => children, })) vi.mock('@/features/auth/login-button', () => ({ + AuthMethodButtonList: () => null, LoginButton: () => null, })) vi.mock('@/features/auth/session-bootstrap-entry', () => ({ - SessionBootstrapEntry: () => null, + SessionBootstrapEntry: () =>

session bootstrap

, })) vi.mock('@/features/auth/use-auth-methods', () => ({ - useAuthMethods: () => ({ data: [] }), + useAuthMethods: () => ({ data: authMethodsFixture.methods, isError: authMethodsFixture.isError }), })) vi.mock('@/features/auth/use-password-login', () => ({ usePasswordLogin: () => ({ - mutateAsync: vi.fn(), + mutateAsync: authMethodsFixture.mutateAsync, isPending: false, error: null, }), })) vi.mock('@/shared/ui/button', () => ({ - Button: ({ children }: { children: unknown }) => children, + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => , })) vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -vi.mock('@/shared/ui/tabs', () => ({ - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, + Input: (props: React.InputHTMLAttributes) => , })) import { renderToStaticMarkup } from 'react-dom/server' import { LoginPage } from './login' describe('LoginPage', () => { + afterEach(() => { + cleanup() + authMethodsFixture.methods = [] + authMethodsFixture.bootstrapEnabled = false + authMethodsFixture.directEnabled = false + authMethodsFixture.isError = false + authMethodsFixture.returnTo = '' + authMethodsFixture.navigate.mockClear() + authMethodsFixture.mutateAsync.mockClear() + }) + it('exports a named component function', () => { expect(typeof LoginPage).toBe('function') }) @@ -75,5 +101,87 @@ describe('LoginPage', () => { expect(html).toContain('login.title') expect(html).toContain('login.subtitle') expect(html).toContain('login.submit') + expect(html).not.toContain('login.tabEnterprise') + expect(html).toContain('login.register') + }) + + it('does not expose password routing details when direct login is configured', () => { + authMethodsFixture.directEnabled = true + const html = renderToStaticMarkup() + + expect(html).toContain('login.submit') + expect(html).not.toContain('login.passwordCompatHint') + }) + + it('returns to the home page after direct login without an explicit destination', async () => { + render() + fireEvent.change(screen.getByLabelText('login.username'), { target: { value: 'user1' } }) + fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'password' } }) + fireEvent.click(screen.getByRole('button', { name: 'login.submit' })) + await waitFor(() => expect(authMethodsFixture.navigate).toHaveBeenCalledWith({ to: '/' })) + }) + + it('returns to the original local page after direct login', async () => { + authMethodsFixture.returnTo = '/skills?tab=mine' + render() + fireEvent.change(screen.getByLabelText('login.username'), { target: { value: 'user1' } }) + fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'password' } }) + fireEvent.click(screen.getByRole('button', { name: 'login.submit' })) + await waitFor(() => expect(authMethodsFixture.navigate).toHaveBeenCalledWith({ to: '/skills?tab=mine' })) + }) + + it('replaces the password form with configured session bootstrap and can switch back', () => { + authMethodsFixture.bootstrapEnabled = true + render() + const personal = screen.getByRole('button', { name: 'login.tabPersonal' }) + const organization = screen.getByRole('button', { name: 'login.tabEnterprise' }) + + expect(personal.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull() + + fireEvent.click(organization) + expect(organization.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByLabelText('login.password').closest('[hidden]')).not.toBeNull() + expect(screen.getByText('session bootstrap').closest('[hidden]')).toBeNull() + + fireEvent.click(personal) + expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull() + expect(screen.getByText('session bootstrap').closest('[hidden]')).not.toBeNull() + }) + + it('does not show an organization entry when bootstrap is not configured', () => { + render() + + expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull() + }) + + it('keeps password login available without a warning when the method catalog fails', () => { + authMethodsFixture.methods = [] + authMethodsFixture.isError = true + render() + + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull() + expect(screen.getByRole('button', { name: 'login.submit' })).toBeTruthy() + }) + + it('hides an unusable session bootstrap entry when the web runtime is not configured', () => { + authMethodsFixture.methods = [{ id: 'proxy', methodType: 'SESSION_BOOTSTRAP' }] + render() + + expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull() + expect(screen.queryByText('session bootstrap')).toBeNull() + }) + + it('preserves configured session bootstrap when the method catalog is unavailable', () => { + authMethodsFixture.methods = [] + authMethodsFixture.bootstrapEnabled = true + authMethodsFixture.isError = true + render() + + fireEvent.click(screen.getByRole('button', { name: 'login.tabEnterprise' })) + expect(screen.getByText('session bootstrap').closest('[hidden]')).toBeNull() }) }) diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 1aab99c4..31ece77d 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,15 +1,16 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Eye, EyeOff } from 'lucide-react' -import { getDirectAuthRuntimeConfig } from '@/api/client' -import { LoginButton } from '@/features/auth/login-button' +import { ArrowRight, Eye, EyeOff, LockKeyhole, UserRound } from 'lucide-react' +import { getSessionBootstrapRuntimeConfig } from '@/api/client' +import { AuthShell } from '@/features/auth/auth-shell' +import { AuthMethodButtonList } from '@/features/auth/login-button' import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' import { useAuthMethods } from '@/features/auth/use-auth-methods' import { usePasswordLogin } from '@/features/auth/use-password-login' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { resolveAuthReturnTo } from '@/shared/lib/auth-route' /** * Authentication entry page. @@ -22,21 +23,19 @@ export function LoginPage() { const navigate = useNavigate() const search = useSearch({ from: '/login' }) const loginMutation = usePasswordLogin() - const directAuthConfig = getDirectAuthRuntimeConfig() + const bootstrapConfig = getSessionBootstrapRuntimeConfig() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) + const [loginMode, setLoginMode] = useState<'personal' | 'organization'>('personal') const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({}) const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' - const { data: authMethods } = useAuthMethods(search.returnTo) - - const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + const returnTo = resolveAuthReturnTo(search.returnTo) + const { data: authMethods, isLoading: authMethodsLoading } = useAuthMethods(returnTo) const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null - const directMethod = directAuthConfig.provider - ? authMethods?.find((method) => - method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider) - : undefined const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP') + const hasOrganizationMethod = bootstrapConfig.enabled + const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT') async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -64,132 +63,135 @@ export function LoginPage() { } return ( -
-
-
-
- S -
-

{t('login.title')}

-

+ +

+
+

{t('login.eyebrow')}

+

{t('login.title')}

+

{t('login.subtitle')}

-
-
- {disabledMessage ? ( -
- {disabledMessage} + {disabledMessage ? ( +
+ {disabledMessage} +
+ ) : null} + + {hasOrganizationMethod ? ( +
+ + +
+ ) : null} + + + + {bootstrapConfig.enabled ? ( + -
+ ) : null} -

+ {authMethodsLoading || hasExternalMethods ? ( +

+
+ {t('login.otherMethods')} +
+ +
+ ) : null} + +

+ {t('login.noAccount')} + {' '} + + {t('login.register')} + +

+ +

{t('login.agreementPrefix')} {isChinese ? null : ' '} @@ -203,6 +205,6 @@ export function LoginPage() {

-
+ ) } diff --git a/web/src/pages/register.test.tsx b/web/src/pages/register.test.tsx index 7395fb3d..ddf36df2 100644 --- a/web/src/pages/register.test.tsx +++ b/web/src/pages/register.test.tsx @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +const authMethodsFixture = vi.hoisted(() => ({ + methods: [] as Array<{ id: string, methodType: string }>, + isLoading: false, +})) + vi.mock('@tanstack/react-router', () => ({ Link: ({ children }: { children: unknown }) => children, useNavigate: () => vi.fn(), @@ -16,8 +21,16 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@/features/auth/auth-shell', () => ({ + AuthShell: ({ children }: { children: unknown }) => children, +})) + vi.mock('@/features/auth/login-button', () => ({ - LoginButton: () => null, + AuthMethodButtonList: () => OAuth buttons, +})) + +vi.mock('@/features/auth/use-auth-methods', () => ({ + useAuthMethods: () => ({ data: authMethodsFixture.methods, isLoading: authMethodsFixture.isLoading }), })) vi.mock('@/features/auth/use-local-auth', () => ({ @@ -65,5 +78,15 @@ describe('RegisterPage', () => { expect(html).toContain('register.title') expect(html).toContain('register.subtitle') expect(html).toContain('register.submit') + expect(html).not.toContain('register.oauthHint') + }) + + it('shows OAuth entry only when providers are advertised', () => { + authMethodsFixture.methods = [{ id: 'github', methodType: 'OAUTH_REDIRECT' }] + const html = renderToStaticMarkup() + + expect(html).toContain('register.oauthHint') + expect(html).toContain('OAuth buttons') + authMethodsFixture.methods = [] }) }) diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index f8a32d05..cc46649d 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -2,12 +2,13 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ApiError } from '@/api/client' -import { LoginButton } from '@/features/auth/login-button' +import { AuthShell } from '@/features/auth/auth-shell' +import { AuthMethodButtonList } from '@/features/auth/login-button' +import { useAuthMethods } from '@/features/auth/use-auth-methods' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { resolveAuthReturnTo } from '@/shared/lib/auth-route' const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,64}$/ const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ @@ -61,7 +62,9 @@ export function RegisterPage() { const [fieldErrors, setFieldErrors] = useState({}) const [formError, setFormError] = useState(null) - const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + const returnTo = resolveAuthReturnTo(search.returnTo) + const { data: authMethods, isLoading: authMethodsLoading } = useAuthMethods(returnTo) + const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT') function validateUsername(value: string) { const trimmed = value.trim() @@ -167,117 +170,110 @@ export function RegisterPage() { } return ( -
- - - {t('register.title')} - {t('register.subtitle')} - - - - - {t('register.tabLocal')} - {t('register.tabOAuth')} - + +
+
+

{t('register.eyebrow')}

+

{t('register.title')}

+

{t('register.subtitle')}

+
- -
-
- - { - setUsername(event.target.value) - if (fieldErrors.username || formError) { - setFieldErrors((current) => ({ ...current, username: undefined })) - setFormError(null) - registerMutation.reset() - } - }} - placeholder={t('register.usernamePlaceholder')} - aria-invalid={fieldErrors.username ? 'true' : 'false'} - onBlur={() => { - setFieldErrors((current) => ({ ...current, username: validateUsername(username) })) - }} - /> - {fieldErrors.username ?

{fieldErrors.username}

: null} -
-
- - { - setEmail(event.target.value) - if (fieldErrors.email || formError) { - setFieldErrors((current) => ({ ...current, email: undefined })) - setFormError(null) - registerMutation.reset() - } - }} - placeholder={t('register.emailPlaceholder')} - required - aria-invalid={fieldErrors.email ? 'true' : 'false'} - onBlur={() => { - setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) - }} - /> - {fieldErrors.email ?

{fieldErrors.email}

: null} -
-
- - { - setPassword(event.target.value) - if (fieldErrors.password || formError) { - setFieldErrors((current) => ({ ...current, password: undefined })) - setFormError(null) - registerMutation.reset() - } - }} - placeholder={t('register.passwordPlaceholder')} - aria-invalid={fieldErrors.password ? 'true' : 'false'} - onBlur={() => { - setFieldErrors((current) => ({ ...current, password: validatePassword(password) })) - }} - /> - {fieldErrors.password ?

{fieldErrors.password}

: null} -
- {formError ?

{formError}

: null} - -

- {t('register.hasAccount')} - {' '} - - {t('register.login')} - -

-
-
+
+
+ + { + setUsername(event.target.value) + if (fieldErrors.username || formError) { + setFieldErrors((current) => ({ ...current, username: undefined })) + setFormError(null) + registerMutation.reset() + } + }} + placeholder={t('register.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, username: validateUsername(username) })) + }} + /> + {fieldErrors.username ?

{fieldErrors.username}

: null} +
+
+ + { + setEmail(event.target.value) + if (fieldErrors.email || formError) { + setFieldErrors((current) => ({ ...current, email: undefined })) + setFormError(null) + registerMutation.reset() + } + }} + placeholder={t('register.emailPlaceholder')} + required + aria-invalid={fieldErrors.email ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) + }} + /> + {fieldErrors.email ?

{fieldErrors.email}

: null} +
+
+ + { + setPassword(event.target.value) + if (fieldErrors.password || formError) { + setFieldErrors((current) => ({ ...current, password: undefined })) + setFormError(null) + registerMutation.reset() + } + }} + placeholder={t('register.passwordPlaceholder')} + aria-invalid={fieldErrors.password ? 'true' : 'false'} + onBlur={() => { + setFieldErrors((current) => ({ ...current, password: validatePassword(password) })) + }} + /> + {fieldErrors.password ?

{fieldErrors.password}

: null} +
+ {formError ?

{formError}

: null} + +

+ {t('register.hasAccount')} + {' '} + + {t('register.login')} + +

+
- -

- {t('register.oauthHint')} -

- -
- - - -
+ {authMethodsLoading || hasExternalMethods ? ( +
+

+ {t('register.oauthHint')} +

+ +
+ ) : null} +
+ ) } diff --git a/web/src/shared/lib/auth-route.test.ts b/web/src/shared/lib/auth-route.test.ts index c15eb46a..eb5888d1 100644 --- a/web/src/shared/lib/auth-route.test.ts +++ b/web/src/shared/lib/auth-route.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it, vi } from 'vitest' import { isRedirect } from '@tanstack/react-router' -import { buildReturnTo, createRequireAuth } from './auth-route' +import { buildReturnTo, createRedirectAuthenticated, createRequireAuth, resolveAuthReturnTo } from './auth-route' describe('auth-route', () => { + it('returns to the original local page or the home page, never an external URL', () => { + expect(resolveAuthReturnTo('/skills?tab=mine#latest')).toBe('/skills?tab=mine#latest') + expect(resolveAuthReturnTo(undefined)).toBe('/') + expect(resolveAuthReturnTo('//example.com')).toBe('/') + expect(resolveAuthReturnTo('/\\example.com')).toBe('/') + expect(resolveAuthReturnTo('/\n/evil.example')).toBe('/') + expect(resolveAuthReturnTo('/\t/evil.example')).toBe('/') + expect(resolveAuthReturnTo('/\r/evil.example')).toBe('/') + expect(resolveAuthReturnTo('/\u007fevil.example')).toBe('/') + expect(resolveAuthReturnTo('https://example.com')).toBe('/') + }) + it('buildReturnTo preserves pathname search and hash', () => { expect(buildReturnTo({ pathname: '/space/global/caldav-calendar', @@ -43,4 +55,41 @@ describe('auth-route', () => { })).resolves.toEqual({ user }) expect(getCurrentUser).toHaveBeenCalledTimes(1) }) + + it('keeps unauthenticated visitors on the login page', async () => { + const redirectAuthenticated = createRedirectAuthenticated(async () => null) + + await expect(redirectAuthenticated({ search: { returnTo: '/dashboard/tokens' } })).resolves.toBeUndefined() + }) + + it('keeps the login page available when the session status check fails', async () => { + const redirectAuthenticated = createRedirectAuthenticated(async () => { + throw new Error('Session status unavailable') + }) + + await expect(redirectAuthenticated({ search: { returnTo: '/dashboard/tokens' } })).resolves.toBeUndefined() + }) + + it('redirects authenticated visitors to the requested local page', async () => { + const redirectAuthenticated = createRedirectAuthenticated(async () => ({ userId: 'user-1' })) + + await expect(redirectAuthenticated({ search: { returnTo: '/dashboard/tokens?tab=active#latest' } })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/dashboard/tokens?tab=active#latest') + expect(error.options.replace).toBe(true) + return true + }) + }) + + it.each([undefined, '//example.com', '/login', '/login?returnTo=%2Flogin', '/register'])('redirects authenticated visitors to home for unusable destinations (%s)', async (returnTo) => { + const redirectAuthenticated = createRedirectAuthenticated(async () => ({ userId: 'user-1' })) + + await expect(redirectAuthenticated({ search: { returnTo } })).rejects.toSatisfy((error: unknown) => { + expect(isRedirect(error)).toBe(true) + if (!isRedirect(error)) return false + expect(error.options.to).toBe('/') + return true + }) + }) }) diff --git a/web/src/shared/lib/auth-route.ts b/web/src/shared/lib/auth-route.ts index df047ec8..ada9d746 100644 --- a/web/src/shared/lib/auth-route.ts +++ b/web/src/shared/lib/auth-route.ts @@ -10,6 +10,49 @@ export function buildReturnTo(location: RouteLocationLike) { return `${location.pathname}${location.searchStr ?? ''}${location.hash ?? ''}` } +export function isSafeAuthReturnTo(value: unknown): value is string { + return typeof value === 'string' + && value.startsWith('/') + && !value.startsWith('//') + && !value.includes('\\') + && !Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 + }) +} + +export function resolveAuthReturnTo(value: unknown) { + return isSafeAuthReturnTo(value) ? value : '/' +} + +function resolveAuthenticatedReturnTo(value: unknown) { + const target = resolveAuthReturnTo(value) + try { + const pathname = decodeURIComponent(new URL(target, 'http://localhost').pathname) + if (/^\/(?:login|register)\/?$/i.test(pathname) || pathname.startsWith('//') || pathname.includes('\\')) { + return '/' + } + } catch { + return '/' + } + return target +} + +export function createRedirectAuthenticated(getCurrentUser: () => Promise) { + return async function redirectAuthenticated({ search }: { search: { returnTo?: string } }) { + let user: unknown + try { + user = await getCurrentUser() + } catch { + // A failed status check must not make the login entry unavailable. + return + } + if (user) { + throw redirect({ to: resolveAuthenticatedReturnTo(search.returnTo), replace: true }) + } + } +} + export function createRequireAuth(getCurrentUser: () => Promise) { return async function requireAuth({ location }: { location: RouteLocationLike }) { const user = await getCurrentUser() diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index 05a40f31..b43aaf8e 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -59,9 +59,10 @@ interface TabsTriggerProps { value: string children: React.ReactNode className?: string + disabled?: boolean } -export function TabsTrigger({ value, children, className }: TabsTriggerProps) { +export function TabsTrigger({ value, children, className, disabled = false }: TabsTriggerProps) { const context = React.useContext(TabsContext) if (!context) throw new Error('TabsTrigger must be used within Tabs') @@ -72,6 +73,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) { type="button" role="tab" aria-selected={isActive} + aria-disabled={disabled} + disabled={disabled} onClick={() => context.setValue(value)} data-state={isActive ? 'active' : 'inactive'} className={cn(