feat(auth): refresh login and registration entry

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-23 17:02:50 +08:00
parent e8fad5962e
commit c8e6988485
23 changed files with 1013 additions and 304 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

View file

@ -31,6 +31,7 @@ import type {
PagedResponse,
ReportDisposition,
AuthMethod,
EnterpriseLoginDiscovery,
OAuthProvider,
User,
ManagedNamespace,
@ -362,6 +363,14 @@ export const authApi = {
}))
},
async discoverEnterpriseLogin(identifier: string, returnTo?: string): Promise<EnterpriseLoginDiscovery> {
return fetchJson<EnterpriseLoginDiscovery>('/api/v1/auth/login-discovery', {
method: 'POST',
headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ identifier, returnTo }),
})
},
async localLogin(request: LocalLoginRequest): Promise<User> {
return fetchJson<User>('/api/v1/auth/local/login', {
method: 'POST',

View file

@ -23,6 +23,19 @@ export interface AuthMethod {
actionUrl: string
}
export interface EnterpriseLoginDiscovery {
publicMethods: AuthMethod[]
organizations: Array<{
slug: string
displayName: string
loginOptions: Array<{
displayName: string
methodType: string
actionUrl: string
}>
}>
}
export type ApiToken = Omit<components['schemas']['TokenSummaryResponse'], 'id' | 'name' | 'tokenPrefix' | 'createdAt'> & {
id: number
name: string

View file

@ -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,

View file

@ -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,

View file

@ -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>
)
}

View file

@ -0,0 +1,19 @@
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('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')
})
})

View 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 text-[10px] font-medium uppercase leading-4 tracking-[0.28em] text-slate-500 dark:text-slate-400 2xl:left-16">Skills<br />Build<br />Together</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>
)
}

View file

@ -0,0 +1,61 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { EnterpriseDiscoveryEntry } from './enterprise-discovery-entry'
const discoverEnterpriseLogin = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
authApi: { discoverEnterpriseLogin },
}))
function renderEntry() {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<EnterpriseDiscoveryEntry returnTo="/dashboard" />
</QueryClientProvider>,
)
}
describe('EnterpriseDiscoveryEntry', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('shows the organization identifier field without another expansion step', () => {
renderEntry()
expect(screen.getByPlaceholderText('login.organizationIdentifier')).toBeTruthy()
})
it('uses server discovery and displays only returned organization login options', async () => {
discoverEnterpriseLogin.mockResolvedValue({
publicMethods: [],
organizations: [{
slug: 'team',
displayName: 'Team',
loginOptions: [{ displayName: 'Company OIDC', methodType: 'ENTERPRISE_REDIRECT', actionUrl: '/api/v1/auth/enterprise/test/start' }],
}],
})
renderEntry()
fireEvent.change(screen.getByPlaceholderText('login.organizationIdentifier'), { target: { value: 'team' } })
fireEvent.click(screen.getByRole('button', { name: 'login.organizationContinue' }))
await waitFor(() => expect(screen.getByText('Company OIDC')).toBeTruthy())
expect(discoverEnterpriseLogin).toHaveBeenCalledWith('team', '/dashboard')
})
it('shows a neutral result when the server has no available organization method', async () => {
discoverEnterpriseLogin.mockResolvedValue({ publicMethods: [], organizations: [] })
renderEntry()
fireEvent.change(screen.getByPlaceholderText('login.organizationIdentifier'), { target: { value: 'unknown' } })
fireEvent.click(screen.getByRole('button', { name: 'login.organizationContinue' }))
await waitFor(() => expect(screen.getByText('login.organizationNotFound')).toBeTruthy())
})
})

View file

@ -0,0 +1,85 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ArrowRight, Building2 } from 'lucide-react'
import { authApi } from '@/api/client'
import { withBasePath } from '@/shared/lib/base-path'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
interface EnterpriseDiscoveryEntryProps {
returnTo: string
}
/** The server resolves the organization and returns only its available login connections. */
export function EnterpriseDiscoveryEntry({ returnTo }: EnterpriseDiscoveryEntryProps) {
const { t } = useTranslation()
const [identifier, setIdentifier] = useState('')
const discovery = useMutation({
mutationFn: (value: string) => authApi.discoverEnterpriseLogin(value, returnTo),
meta: { skipGlobalErrorHandler: true },
})
return (
<div className="space-y-3">
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
const value = identifier.trim()
if (value) discovery.mutate(value)
}}
>
<div className="space-y-2">
<label htmlFor="organization-identifier" className="text-sm font-medium">{t('login.organizationIdentifier')}</label>
<div className="relative">
<Building2 aria-hidden="true" className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="organization-identifier"
autoComplete="email"
className="h-11 pl-10 xl:h-10"
maxLength={320}
placeholder={t('login.organizationIdentifier')}
value={identifier}
onChange={(event) => {
setIdentifier(event.target.value)
discovery.reset()
}}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('login.organizationLoginHint')}</p>
<Button type="submit" disabled={!identifier.trim() || discovery.isPending} 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">
{discovery.isPending ? t('login.organizationSearching') : t('login.organizationContinue')}
<ArrowRight aria-hidden="true" className="ml-2 h-4 w-4" />
</Button>
</form>
{discovery.isError ? <p role="alert" className="text-sm text-destructive">{discovery.error.message}</p> : null}
{discovery.data && discovery.data.organizations.length === 0 ? (
<p role="status" className="text-sm text-muted-foreground">{t('login.organizationNotFound')}</p>
) : null}
{discovery.data?.organizations.map((organization) => (
<div key={organization.slug} className="space-y-2 rounded-xl border border-border p-4">
<p className="text-sm font-medium">{organization.displayName}</p>
{organization.loginOptions.map((option) => (
<Button
key={option.actionUrl}
type="button"
variant="outline"
className="w-full justify-between"
onClick={() => {
if (option.actionUrl.startsWith('/api/v1/auth/enterprise/')) {
window.location.href = withBasePath(option.actionUrl)
}
}}
>
{option.displayName}
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</Button>
))}
</div>
))}
</div>
)
}

View 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')
})
})

View file

@ -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}
/>
)
}

View file

@ -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-violet-600 text-white shadow-[0_10px_30px_-18px_rgb(124_58_237/0.75)] hover:bg-violet-700"
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">

View file

@ -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', () => {

View file

@ -329,7 +329,28 @@
},
"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",
"organizationLogin": "Sign in through your organization",
"organizationLoginHint": "Enter your organization ID or work email to find its available sign-in methods.",
"organizationIdentifier": "Organization ID or work email",
"organizationContinue": "Find sign-in methods",
"organizationSearching": "Searching...",
"organizationNotFound": "No organization sign-in method is available. Check your details or contact your administrator.",
"methodsUnavailable": "Sign-in methods are temporarily unavailable. Please try again.",
"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",
@ -352,6 +373,8 @@
"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 +383,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 +1502,35 @@
},
"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",
"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"
},

View file

@ -329,7 +329,28 @@
},
"login": {
"title": "Вход в SkillHub",
"subtitle": "Выберите способ продолжения",
"subtitle": "Один аккаунт для удобной совместной работы с навыками",
"eyebrow": "С возвращением",
"tabEnterprise": "Вход через организацию",
"tabPersonal": "Личный вход",
"loginMode": "Способ входа",
"enterpriseTitle": "Корпоративный SSO",
"enterpriseHint": "Если администраторы настроили рабочий вход, можно продолжить с корпоративной идентичностью.",
"enterpriseConfigHint": "Рабочие способы входа включает администратор.",
"noEnterpriseMethods": "Рабочий вход пока недоступен. Используйте личный аккаунт или обратитесь к администратору.",
"personalTitle": "Вход по паролю",
"personalHint": "Используйте имя пользователя и пароль SkillHub.",
"otherMethods": "Или войдите через",
"orgLoginHint": "Для организаций с настроенным единым входом",
"organizationLogin": "Войти через организацию",
"organizationLoginHint": "Введите код организации или рабочую почту, чтобы найти доступные способы входа.",
"organizationIdentifier": "Код организации или рабочая почта",
"organizationContinue": "Найти способы входа",
"organizationSearching": "Поиск...",
"organizationNotFound": "Способ входа для организации не найден. Проверьте данные или обратитесь к администратору.",
"methodsUnavailable": "Способы входа временно недоступны. Повторите попытку позже.",
"externalIdentityMethods": "Внешние источники идентификации",
"externalIdentityHint": "Feishu, DingTalk, GitHub, GitLab и похожие входы публикуются сервером. Принадлежность аккаунта определяют привязки и членство в организациях на backend.",
"tabPassword": "Пароль",
"tabOAuth": "OAuth",
"username": "Имя пользователя",
@ -352,6 +373,8 @@
"enterpriseSsoAutoHint": "В этом развёртывании включено автоматическое зондирование {{name}}. Если оно не сработает, продолжайте стандартными способами входа.",
"enterpriseSsoAction": "Попробовать {{name}}",
"enterpriseSsoSubmitting": "Пробуем {{name}}...",
"enterpriseContinue": "Войти с рабочим аккаунтом",
"switchToPersonal": "Использовать личный аккаунт",
"agreementPrefix": "Входя в систему, вы соглашаетесь с",
"terms": "Условиями использования",
"and": "и",
@ -360,6 +383,7 @@
"register": {
"title": "Создать аккаунт",
"subtitle": "Зарегистрируйтесь локально или войдите через OAuth.",
"eyebrow": "Создать личный аккаунт",
"tabLocal": "Локальный аккаунт",
"tabOAuth": "OAuth",
"username": "Имя пользователя",
@ -1589,8 +1613,35 @@
},
"loginButton": {
"loading": "Загрузка...",
"loadingShort": "Загрузка",
"loginWith": "Войти через {{name}}"
},
"authShell": {
"eyebrow": "SkillHub",
"backHome": "На главную",
"heroTitle": "Открывайте и сохраняйте\nнавыки вашей команды",
"heroSubtitle": "Начните с SKILL.md.\nПревратите опыт в доступный команде навык.",
"heroFooter": "Собирайте знания команды и раскрывайте ценность навыков",
"capabilities": "Возможности SkillHub",
"discoverTitle": "Поиск навыков",
"discoverDescription": "Находите навыки и практики, которые команда может использовать повторно.",
"shareTitle": "Обмен знаниями",
"shareDescription": "Публикуйте версии и передавайте опыт дальше.",
"governTitle": "Безопасное управление",
"governDescription": "Работайте вместе с понятными правами доступа.",
"previewNavLibrary": "Библиотека",
"previewNavMine": "Мои навыки",
"previewNavTeam": "Команда",
"previewSearch": "Поиск навыков, ключевых слов или команд",
"previewChipAll": "Все",
"previewChipProduct": "Продукт",
"previewChipData": "Данные",
"previewChipCommon": "Общие",
"previewRowProduct": "Продукт",
"previewRowData": "Данные",
"previewRowDev": "Разработка",
"previewRowCommon": "Общие"
},
"namespaceStatus": {
"active": "Активно",
"frozen": "Заморожено",

View file

@ -329,7 +329,28 @@
},
"login": {
"title": "登录 SkillHub",
"subtitle": "选择一个方式登录以继续",
"subtitle": "一个账号,开启更高效的技能协作",
"eyebrow": "欢迎回来",
"tabEnterprise": "组织登录",
"tabPersonal": "个人登录",
"loginMode": "登录方式",
"enterpriseTitle": "企业单点登录",
"enterpriseHint": "当管理员配置了企业登录入口时,可直接使用企业身份继续。",
"enterpriseConfigHint": "企业登录入口由管理员开通。",
"noEnterpriseMethods": "当前还没有可用的企业登录入口。你可以先使用个人账号登录,或联系管理员开通。",
"personalTitle": "账号密码登录",
"personalHint": "使用 SkillHub 用户名和密码登录。",
"otherMethods": "或使用以下方式登录",
"orgLoginHint": "适用于已配置企业单点登录的组织",
"organizationLogin": "通过组织登录",
"organizationLoginHint": "输入组织标识或工作邮箱,我们会查找该组织已开通的登录方式。",
"organizationIdentifier": "组织标识或工作邮箱",
"organizationContinue": "查找登录方式",
"organizationSearching": "查找中...",
"organizationNotFound": "未找到可用的组织登录方式。请核对信息,或联系组织管理员。",
"methodsUnavailable": "暂时无法获取登录方式,请稍后重试。",
"externalIdentityMethods": "外部身份源",
"externalIdentityHint": "飞书、钉钉、GitHub、GitLab 等入口由服务端配置;登录后的账号归属由后端绑定和组织关系决定。",
"tabPassword": "账号密码",
"tabOAuth": "OAuth",
"username": "用户名",
@ -352,6 +373,8 @@
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
"enterpriseSsoAction": "尝试 {{name}} 登录",
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
"enterpriseContinue": "通过企业账号登录",
"switchToPersonal": "改用个人账号",
"agreementPrefix": "登录即表示你同意我们的",
"terms": "服务条款",
"and": "和",
@ -360,6 +383,7 @@
"register": {
"title": "创建账号",
"subtitle": "支持本地注册,也可以直接使用 OAuth 登录进入平台。",
"eyebrow": "创建个人账号",
"tabLocal": "本地账号",
"tabOAuth": "OAuth",
"username": "用户名",
@ -1478,8 +1502,35 @@
},
"loginButton": {
"loading": "加载中...",
"loadingShort": "加载中",
"loginWith": "使用 {{name}} 登录"
},
"authShell": {
"eyebrow": "SkillHub",
"backHome": "返回首页",
"heroTitle": "让团队的技能\n被发现、被沉淀、被复用",
"heroSubtitle": "从一份 SKILL.md 开始,\n把经验转化为可共享的团队能力。",
"heroFooter": "汇聚团队智慧,释放技能价值",
"capabilities": "SkillHub 的能力",
"discoverTitle": "发现技能",
"discoverDescription": "找到团队可复用的技能包与实践。",
"shareTitle": "沉淀共享",
"shareDescription": "发布版本,让经验持续流转。",
"governTitle": "安全治理",
"governDescription": "在清晰的权限下协作与管理。",
"previewNavLibrary": "技能库",
"previewNavMine": "我的技能",
"previewNavTeam": "团队空间",
"previewSearch": "搜索技能、关键词或团队",
"previewChipAll": "全部",
"previewChipProduct": "产品团队",
"previewChipData": "数据团队",
"previewChipCommon": "通用",
"previewRowProduct": "产品",
"previewRowData": "数据",
"previewRowDev": "研发",
"previewRowCommon": "通用"
},
"ratingInput": {
"yourRating": "你的评分: {{score}} 星"
},

View file

@ -1,5 +1,14 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
const authMethodsFixture = vi.hoisted(() => ({
methods: [{ id: 'organization', methodType: 'ENTERPRISE_DISCOVERY' }],
bootstrapEnabled: false,
isError: false,
}))
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => vi.fn(),
@ -18,24 +27,37 @@ 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 }),
getSessionBootstrapRuntimeConfig: () => ({ enabled: authMethodsFixture.bootstrapEnabled, provider: 'proxy' }),
}))
vi.mock('@/features/auth/auth-shell', () => ({
AuthShell: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/features/auth/enterprise-discovery-entry', () => ({
EnterpriseDiscoveryEntry: () => <p>organization discovery</p>,
}))
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', () => ({
@ -47,24 +69,24 @@ vi.mock('@/features/auth/use-password-login', () => ({
}))
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 = [{ id: 'organization', methodType: 'ENTERPRISE_DISCOVERY' }]
authMethodsFixture.bootstrapEnabled = false
authMethodsFixture.isError = false
})
it('exports a named component function', () => {
expect(typeof LoginPage).toBe('function')
})
@ -75,5 +97,52 @@ describe('LoginPage', () => {
expect(html).toContain('login.title')
expect(html).toContain('login.subtitle')
expect(html).toContain('login.submit')
expect(html).toContain('login.tabPersonal')
expect(html).toContain('login.tabEnterprise')
expect(html).toContain('login.register')
})
it('replaces the password form with organization discovery and can switch back', () => {
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('organization discovery').closest('[hidden]')).toBeNull()
fireEvent.click(personal)
expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull()
expect(screen.getByText('organization discovery').closest('[hidden]')).not.toBeNull()
})
it('does not show an organization entry when the server does not advertise one', () => {
authMethodsFixture.methods = []
render(<LoginPage />)
expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull()
expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull()
})
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()
})
})

View file

@ -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 { getDirectAuthRuntimeConfig, getSessionBootstrapRuntimeConfig } from '@/api/client'
import { AuthShell } from '@/features/auth/auth-shell'
import { EnterpriseDiscoveryEntry } from '@/features/auth/enterprise-discovery-entry'
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'
/**
* Authentication entry page.
@ -23,12 +24,14 @@ export function LoginPage() {
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 { data: authMethods, isLoading: authMethodsLoading, isError: authMethodsError } = useAuthMethods(search.returnTo)
const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard'
const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null
@ -37,6 +40,9 @@ export function LoginPage() {
method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider)
: undefined
const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP')
const discoveryMethod = authMethods?.find((method) => method.methodType === 'ENTERPRISE_DISCOVERY')
const hasOrganizationMethod = Boolean(discoveryMethod || bootstrapConfig.enabled)
const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
@ -64,132 +70,152 @@ 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}>
{directAuthConfig.enabled ? (
<p className="rounded-xl border border-blue-500/20 bg-blue-500/10 px-3 py-2 text-sm text-blue-700 dark:text-blue-300">
{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>
<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>
{discoveryMethod ? (
<div hidden={loginMode !== 'organization'}>
<EnterpriseDiscoveryEntry returnTo={returnTo} />
</div>
) : null}
{bootstrapConfig.enabled ? (
<div hidden={loginMode !== 'organization' || Boolean(discoveryMethod)} 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">
{authMethodsError ? (
<p role="alert" className="text-sm text-destructive">{t('login.methodsUnavailable')}</p>
) : null}
{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 +229,6 @@ export function LoginPage() {
</Link>
</p>
</div>
</div>
</AuthShell>
)
}

View file

@ -16,6 +16,10 @@ vi.mock('react-i18next', async () => {
}
})
vi.mock('@/features/auth/auth-shell', () => ({
AuthShell: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/features/auth/login-button', () => ({
LoginButton: () => null,
}))

View file

@ -2,12 +2,11 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError } from '@/api/client'
import { AuthShell } from '@/features/auth/auth-shell'
import { LoginButton } from '@/features/auth/login-button'
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'
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,64}$/
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
@ -167,117 +166,108 @@ 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>
<div className="space-y-3 border-t border-border/70 pt-5">
<p className="text-sm text-muted-foreground">
{t('register.oauthHint')}
</p>
<LoginButton returnTo={returnTo} compact />
</div>
</div>
</AuthShell>
)
}

View file

@ -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(