mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat(auth): refresh login and registration entry
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
e8fad5962e
commit
c8e6988485
23 changed files with 1013 additions and 304 deletions
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 |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
19
web/src/features/auth/auth-shell.test.tsx
Normal file
19
web/src/features/auth/auth-shell.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
61
web/src/features/auth/enterprise-discovery-entry.test.tsx
Normal file
61
web/src/features/auth/enterprise-discovery-entry.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
85
web/src/features/auth/enterprise-discovery-entry.tsx
Normal file
85
web/src/features/auth/enterprise-discovery-entry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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-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">
|
||||
|
|
|
|||
|
|
@ -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,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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "Заморожено",
|
||||
|
|
|
|||
|
|
@ -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}} 星"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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