mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
Merge pull request #901 from iflytek/feature/login-page-refresh-pr
feat(auth): refresh login and registration entry
This commit is contained in:
commit
39a7081ff4
24 changed files with 1240 additions and 327 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
BIN
web/public/login-skill-art-dark.png
Normal file
BIN
web/public/login-skill-art-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 621 KiB |
BIN
web/public/login-skill-art-light.png
Normal file
BIN
web/public/login-skill-art-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 583 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className={getAppHeaderClassName(isHeaderElevated)} style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight flex-shrink-0" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
SkillHub
|
||||
</Link>
|
||||
<header
|
||||
className={isSplitAuthRoute ? 'absolute right-5 top-4 z-50 flex items-center sm:right-10' : getAppHeaderClassName(isHeaderElevated)}
|
||||
style={isSplitAuthRoute ? undefined : { borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
{!isSplitAuthRoute ? (
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight flex-shrink-0" style={{ color: 'hsl(var(--foreground))' }}>
|
||||
SkillHub
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Desktop nav — lg+ only */}
|
||||
<nav className="hidden lg:flex items-center gap-5 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{navItems.map((item) => {
|
||||
if (item.auth && !user) return null
|
||||
const active = isActive(item.to, item.exact)
|
||||
{!isAuthEntryRoute ? (
|
||||
<nav className="hidden lg:flex items-center gap-5 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{navItems.map((item) => {
|
||||
if (item.auth && !user) return null
|
||||
const active = isActive(item.to, item.exact)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={
|
||||
active
|
||||
? 'px-4 py-1.5 rounded-full text-sm font-medium bg-foreground text-background shadow-[0_1px_2px_0_rgb(0_0_0/0.12)]'
|
||||
: 'px-4 py-1.5 rounded-full text-sm font-medium hover:opacity-90 transition-opacity duration-150'
|
||||
}
|
||||
style={active ? undefined : { color: 'hsl(var(--foreground) / 0.65)' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={
|
||||
active
|
||||
? 'px-4 py-1.5 rounded-full text-sm font-medium bg-foreground text-background shadow-[0_1px_2px_0_rgb(0_0_0/0.12)]'
|
||||
: 'px-4 py-1.5 rounded-full text-sm font-medium hover:opacity-90 transition-opacity duration-150'
|
||||
}
|
||||
style={active ? undefined : { color: 'hsl(var(--foreground) / 0.65)' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
{/* Hamburger — visible below lg */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden inline-flex items-center justify-center rounded-lg p-2 hover:bg-accent transition-colors"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label={t(mobileMenuOpen ? 'layout.closeNavigation' : 'layout.openNavigation')}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
{!isAuthEntryRoute ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden inline-flex items-center justify-center rounded-lg p-2 hover:bg-accent transition-colors"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label={t(mobileMenuOpen ? 'layout.closeNavigation' : 'layout.openNavigation')}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
) : null}
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{user && <NotificationBell />}
|
||||
{isLoading ? null : user ? (
|
||||
<UserMenu user={user} />
|
||||
) : (
|
||||
) : !isAuthEntryRoute ? (
|
||||
<Link
|
||||
to="/login"
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile nav dropdown */}
|
||||
{mobileMenuOpen ? (
|
||||
{mobileMenuOpen && !isAuthEntryRoute ? (
|
||||
<div className="lg:hidden sticky top-[52px] z-40 border-b border-border bg-background/95 backdrop-blur-xl">
|
||||
<nav className="flex flex-col px-4 py-3 gap-1">
|
||||
{navItems.map((item) => {
|
||||
|
|
@ -219,6 +230,7 @@ export function Layout() {
|
|||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
{!isAuthEntryRoute ? (
|
||||
<footer className="relative z-10 mt-auto border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-12 md:px-12 md:py-16">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-5">
|
||||
|
|
@ -273,6 +285,7 @@ export function Layout() {
|
|||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): { 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<string, unknown>) => ({
|
||||
returnTo: typeof search.returnTo === 'string' ? search.returnTo : '',
|
||||
returnTo: isSafeAuthReturnTo(search.returnTo) ? search.returnTo : '',
|
||||
}),
|
||||
component: RegisterPage,
|
||||
})
|
||||
|
|
|
|||
20
web/src/features/auth/auth-shell.test.tsx
Normal file
20
web/src/features/auth/auth-shell.test.tsx
Normal file
|
|
@ -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(<AuthShell><div>Login form</div></AuthShell>)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
62
web/src/features/auth/auth-shell.tsx
Normal file
62
web/src/features/auth/auth-shell.tsx
Normal file
|
|
@ -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 (
|
||||
<section className={cn('relative w-full', className)}>
|
||||
<a
|
||||
href={withBasePath('/')}
|
||||
className="absolute left-5 top-5 z-20 inline-flex items-center gap-2 text-sm font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 xl:hidden"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
{t('authShell.backHome')}
|
||||
</a>
|
||||
<div className="grid min-h-svh w-full bg-background xl:grid-cols-[53fr_47fr]">
|
||||
<aside className="sticky top-0 hidden h-svh overflow-hidden border-r border-slate-200 bg-slate-50/60 text-zinc-950 dark:border-slate-800 dark:bg-slate-950 dark:text-zinc-50 xl:block">
|
||||
<a
|
||||
href={withBasePath('/')}
|
||||
className="absolute left-12 top-10 z-20 inline-flex items-center gap-2 text-sm font-medium text-zinc-700 transition-colors hover:text-zinc-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current dark:text-zinc-300 dark:hover:text-white 2xl:left-16"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
{t('authShell.backHome')}
|
||||
</a>
|
||||
|
||||
<div className="relative z-10 px-12 pt-28 2xl:px-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandMark className="h-8 w-8" alt="" />
|
||||
<span className="text-lg font-semibold tracking-tight">SkillHub</span>
|
||||
</div>
|
||||
<h2 className="mt-7 text-[clamp(4.5rem,6.5vw,6rem)] font-black leading-none tracking-[-0.04em]">SkillHub</h2>
|
||||
<p className="mt-5 max-w-[420px] whitespace-pre-line text-2xl font-semibold leading-snug tracking-tight">{t('authShell.heroTitle')}</p>
|
||||
<p className="mt-4 max-w-[350px] whitespace-pre-line text-sm leading-6 text-zinc-600 dark:text-zinc-400">{t('authShell.heroSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-[3%] -right-[3%] h-[60%] w-[78%]" aria-hidden="true">
|
||||
<img src={withBasePath('/login-skill-art-light.png')} alt="" width="1484" height="1060" className="h-full w-full object-contain dark:hidden" />
|
||||
<img src={withBasePath('/login-skill-art-dark.png')} alt="" width="1484" height="1060" className="hidden h-full w-full object-contain dark:block" />
|
||||
</div>
|
||||
|
||||
<p className="absolute bottom-9 left-12 z-10 whitespace-pre-line text-[10px] font-medium uppercase leading-4 tracking-[0.28em] text-slate-500 dark:text-slate-400 2xl:left-16">{t('authShell.shortTagline')}</p>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-svh items-start justify-center px-5 pb-8 pt-20 sm:px-10 xl:px-12 xl:py-24">
|
||||
<div className="w-full max-w-[500px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
86
web/src/features/auth/login-button.test.tsx
Normal file
86
web/src/features/auth/login-button.test.tsx
Normal file
|
|
@ -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<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, string>) => (
|
||||
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<HTMLButtonElement>) => (
|
||||
<button type="button" {...props}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
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(<AuthMethodButtonList methods={methods} />)
|
||||
|
||||
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(<AuthMethodButtonList methods={methods.filter((method) => method.methodType !== 'OAUTH_REDIRECT')} emptyFallback={<p>No methods</p>} />)
|
||||
|
||||
expect(screen.getByText('No methods')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('navigates to the configured OAuth action URL', () => {
|
||||
vi.stubGlobal('location', { href: 'http://localhost/login' })
|
||||
|
||||
render(<AuthMethodButtonList methods={methods.slice(0, 1)} />)
|
||||
fireEvent.click(screen.getByText('Login with GitHub'))
|
||||
|
||||
expect(window.location.href).toBe('/oauth2/authorization/github')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
<img
|
||||
src={withBasePath(`/${normalizedProvider}-logo.svg`)}
|
||||
alt={provider}
|
||||
className="w-5 h-5 mr-3"
|
||||
className={cn(compact ? 'h-4 w-4' : 'h-5 w-5 mr-3')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full h-12" disabled>
|
||||
<div className="w-5 h-5 rounded-full animate-shimmer mr-3" />
|
||||
{t('loginButton.loading')}
|
||||
<div className={cn(compact ? 'grid grid-cols-2 gap-2 sm:grid-cols-4' : 'space-y-3')}>
|
||||
<Button className="h-12" disabled variant="outline">
|
||||
<div className={cn('rounded-full animate-shimmer', compact ? 'h-4 w-4' : 'mr-3 h-5 w-5')} />
|
||||
{compact ? t('loginButton.loadingShort') : t('loginButton.loading')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return <>{emptyFallback}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={cn(compact ? 'grid grid-cols-2 gap-2 sm:grid-cols-4' : 'space-y-3')}>
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
className="w-full h-12 text-base"
|
||||
className={cn(
|
||||
compact ? 'h-11 justify-center px-2 text-sm xl:h-10' : 'w-full h-12 text-base',
|
||||
)}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = withBasePath(provider.actionUrl)
|
||||
}}
|
||||
>
|
||||
<OAuthIcon provider={provider.provider} />
|
||||
{t('loginButton.loginWith', { name: provider.displayName })}
|
||||
<OAuthIcon provider={provider.provider} compact={compact} />
|
||||
<span className={compact ? 'truncate' : undefined}>
|
||||
{compact ? provider.displayName : t('loginButton.loginWith', { name: provider.displayName })}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<AuthMethodButtonList
|
||||
methods={data}
|
||||
isLoading={isLoading}
|
||||
compact={compact}
|
||||
emptyFallback={emptyFallback}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ import { useSessionBootstrap } from './use-session-bootstrap'
|
|||
interface SessionBootstrapEntryProps {
|
||||
onAuthenticated: () => Promise<void>
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
className="h-11 w-full rounded-xl bg-[#315b86] text-white hover:bg-[#25496d] dark:bg-sky-300 dark:text-sky-950 dark:hover:bg-sky-200 xl:h-10"
|
||||
type="button"
|
||||
disabled={bootstrapMutation.isPending}
|
||||
onClick={() => {
|
||||
void bootstrapMutation.mutateAsync(config.provider!, {
|
||||
onSuccess: async () => {
|
||||
await onAuthenticated()
|
||||
},
|
||||
onError: () => {
|
||||
// Keep the page usable for other login methods.
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{bootstrapMutation.isPending
|
||||
? t('login.enterpriseSsoSubmitting', { name: providerName })
|
||||
: t('login.enterpriseContinue')}
|
||||
</Button>
|
||||
|
||||
{manualError ? (
|
||||
<p className="text-sm text-red-600">{manualError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "Заморожено",
|
||||
|
|
|
|||
|
|
@ -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}} 星"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: () => <p>session bootstrap</p>,
|
||||
}))
|
||||
|
||||
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<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
}))
|
||||
|
||||
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<HTMLInputElement>) => <input {...props} />,
|
||||
}))
|
||||
|
||||
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(<LoginPage />)
|
||||
|
||||
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(<LoginPage />)
|
||||
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(<LoginPage />)
|
||||
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(<LoginPage />)
|
||||
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(<LoginPage />)
|
||||
|
||||
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(<LoginPage />)
|
||||
|
||||
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(<LoginPage />)
|
||||
|
||||
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(<LoginPage />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login.tabEnterprise' }))
|
||||
expect(screen.getByText('session bootstrap').closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
|
@ -64,132 +63,135 @@ export function LoginPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 animate-fade-up">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary/70 items-center justify-center shadow-glow mb-4">
|
||||
<span className="text-primary-foreground font-bold text-2xl">S</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{t('login.title')}</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
<AuthShell>
|
||||
<div className="flex flex-col gap-5 animate-fade-up xl:min-h-[calc(100svh-12rem)]">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('login.eyebrow')}</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">{t('login.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('login.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-strong p-8 rounded-2xl">
|
||||
<div className="space-y-6">
|
||||
{disabledMessage ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{disabledMessage}
|
||||
{disabledMessage ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{disabledMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasOrganizationMethod ? (
|
||||
<div role="group" aria-label={t('login.loginMode')} className="grid grid-cols-2 rounded-lg border border-slate-200 bg-slate-50 p-1 dark:border-slate-700 dark:bg-slate-900">
|
||||
<button type="button" aria-pressed={loginMode === 'personal'} onClick={() => setLoginMode('personal')} className={`h-11 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 xl:h-10 ${loginMode === 'personal' ? 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100' : 'text-muted-foreground hover:text-foreground'}`}>
|
||||
{t('login.tabPersonal')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={loginMode === 'organization'} onClick={() => setLoginMode('organization')} className={`h-11 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 xl:h-10 ${loginMode === 'organization' ? 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100' : 'text-muted-foreground hover:text-foreground'}`}>
|
||||
{t('login.tabEnterprise')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div hidden={loginMode !== 'personal'}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="username">{t('login.username')}</label>
|
||||
<div className="relative">
|
||||
<UserRound aria-hidden="true" className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value)
|
||||
if (fieldErrors.username) {
|
||||
setFieldErrors((current) => ({ ...current, username: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
aria-invalid={fieldErrors.username ? 'true' : 'false'}
|
||||
className="h-11 pl-10 xl:h-10"
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.username ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium" htmlFor="password">{t('login.password')}</label>
|
||||
<Link to="/reset-password" className="text-sm font-medium text-sky-700 hover:underline dark:text-sky-300">
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<LockKeyhole aria-hidden="true" className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value)
|
||||
if (fieldErrors.password) {
|
||||
setFieldErrors((current) => ({ ...current, password: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
className="h-11 pl-10 pr-12 xl:h-10"
|
||||
aria-invalid={fieldErrors.password ? 'true' : 'false'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.password ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{loginMutation.error ? (
|
||||
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
|
||||
) : null}
|
||||
<Button className="h-11 w-full bg-[#315b86] text-white hover:bg-[#25496d] dark:bg-sky-300 dark:text-sky-950 dark:hover:bg-sky-200 xl:h-10" disabled={loginMutation.isPending} type="submit">
|
||||
{loginMutation.isPending ? t('login.submitting') : t('login.submit')}
|
||||
<ArrowRight aria-hidden="true" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{bootstrapConfig.enabled ? (
|
||||
<div hidden={loginMode !== 'organization'} className="space-y-2">
|
||||
<SessionBootstrapEntry
|
||||
methodDisplayName={bootstrapMethod?.displayName}
|
||||
onAuthenticated={() => navigate({ to: returnTo })}
|
||||
compact
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="password" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>
|
||||
<TabsTrigger value="oauth">{t('login.tabOAuth')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{directAuthConfig.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('login.passwordCompatHint', {
|
||||
name: directMethod?.displayName ?? directAuthConfig.provider,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="username">{t('login.username')}</label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value)
|
||||
if (fieldErrors.username) {
|
||||
setFieldErrors((current) => ({ ...current, username: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
aria-invalid={fieldErrors.username ? 'true' : 'false'}
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">{t('login.password')}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value)
|
||||
if (fieldErrors.password) {
|
||||
setFieldErrors((current) => ({ ...current, password: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
className="pr-12"
|
||||
aria-invalid={fieldErrors.password ? 'true' : 'false'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.password ? (
|
||||
<p className="text-sm text-red-600">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{loginMutation.error ? (
|
||||
<p className="text-sm text-red-600">{loginMutation.error.message}</p>
|
||||
) : null}
|
||||
<Button className="w-full" disabled={loginMutation.isPending} type="submit">
|
||||
{loginMutation.isPending ? t('login.submitting') : t('login.submit')}
|
||||
</Button>
|
||||
<p className="text-center text-sm">
|
||||
<Link to="/reset-password" className="font-medium text-primary hover:underline">
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('login.noAccount')}
|
||||
{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
search={{ returnTo }}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('login.register')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oauth" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('login.oauthHint')}
|
||||
</p>
|
||||
<LoginButton returnTo={returnTo} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<p className="text-sm text-muted-foreground">{t('login.orgLoginHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{authMethodsLoading || hasExternalMethods ? (
|
||||
<section className="space-y-3" aria-label={t('login.otherMethods')}>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border">
|
||||
{t('login.otherMethods')}
|
||||
</div>
|
||||
<AuthMethodButtonList methods={authMethods} isLoading={authMethodsLoading} compact />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('login.noAccount')}
|
||||
{' '}
|
||||
<Link to="/register" search={{ returnTo }} className="font-medium text-sky-700 hover:underline dark:text-sky-300">
|
||||
{t('login.register')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="mt-auto text-center text-xs text-muted-foreground">
|
||||
{t('login.agreementPrefix')}
|
||||
{isChinese ? null : ' '}
|
||||
<Link to="/terms" className="text-primary hover:underline">
|
||||
|
|
@ -203,6 +205,6 @@ export function LoginPage() {
|
|||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => <span>OAuth buttons</span>,
|
||||
}))
|
||||
|
||||
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(<RegisterPage />)
|
||||
|
||||
expect(html).toContain('register.oauthHint')
|
||||
expect(html).toContain('OAuth buttons')
|
||||
authMethodsFixture.methods = []
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<RegisterFieldErrors>({})
|
||||
const [formError, setFormError] = useState<string | null>(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 (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-border bg-card/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('register.title')}</CardTitle>
|
||||
<CardDescription>{t('register.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="local" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="local">{t('register.tabLocal')}</TabsTrigger>
|
||||
<TabsTrigger value="oauth">{t('register.tabOAuth')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<AuthShell>
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('register.eyebrow')}</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">{t('register.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">{t('register.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="local">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-username">{t('register.username')}</label>
|
||||
<Input
|
||||
id="register-username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.username}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-email">{t('register.email')}</label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.email}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-password">{t('register.password')}</label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.password}</p> : null}
|
||||
</div>
|
||||
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
||||
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
|
||||
{registerMutation.isPending ? t('register.submitting') : t('register.submit')}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('register.hasAccount')}
|
||||
{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ returnTo }}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</TabsContent>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-username">{t('register.username')}</label>
|
||||
<Input
|
||||
id="register-username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.username}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-email">{t('register.email')}</label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.email}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="register-password">{t('register.password')}</label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
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 ? <p className="text-sm text-red-600">{fieldErrors.password}</p> : null}
|
||||
</div>
|
||||
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
||||
<Button className="w-full" disabled={registerMutation.isPending} type="submit">
|
||||
{registerMutation.isPending ? t('register.submitting') : t('register.submit')}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('register.hasAccount')}
|
||||
{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
search={{ returnTo }}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<TabsContent value="oauth" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('register.oauthHint')}
|
||||
</p>
|
||||
<LoginButton returnTo={returnTo} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{authMethodsLoading || hasExternalMethods ? (
|
||||
<div className="space-y-3 border-t border-border/70 pt-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('register.oauthHint')}
|
||||
</p>
|
||||
<AuthMethodButtonList methods={authMethods} isLoading={authMethodsLoading} compact />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<unknown>) {
|
||||
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<unknown>) {
|
||||
return async function requireAuth({ location }: { location: RouteLocationLike }) {
|
||||
const user = await getCurrentUser()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue