diff --git a/web/public/login-skill-art-dark.png b/web/public/login-skill-art-dark.png new file mode 100644 index 00000000..620cc36b Binary files /dev/null and b/web/public/login-skill-art-dark.png differ diff --git a/web/public/login-skill-art-light.png b/web/public/login-skill-art-light.png new file mode 100644 index 00000000..65ba6a75 Binary files /dev/null and b/web/public/login-skill-art-light.png differ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a724997e..2aa8ae26 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + return fetchJson('/api/v1/auth/login-discovery', { + method: 'POST', + headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ identifier, returnTo }), + }) + }, + async localLogin(request: LocalLoginRequest): Promise { return fetchJson('/api/v1/auth/local/login', { method: 'POST', diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bbc37549..df58d751 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 & { id: number name: string diff --git a/web/src/app/layout-main-content.test.ts b/web/src/app/layout-main-content.test.ts index 3a0e6f8b..6ef027d4 100644 --- a/web/src/app/layout-main-content.test.ts +++ b/web/src/app/layout-main-content.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { + AUTH_MAIN_CLASS_NAME, CENTERED_DASHBOARD_CONTENT_CLASS_NAME, CENTERED_MAIN_CLASS_NAME, CENTERED_SEARCH_CONTENT_CLASS_NAME, DEFAULT_MAIN_CLASS_NAME, + SPLIT_AUTH_MAIN_CLASS_NAME, getAppMainContentLayout, resolveAppMainContentPathname, } from './layout-main-content' @@ -36,6 +38,24 @@ describe('getAppMainContentLayout', () => { expect(layout.contentClassName).toContain('max-w-[1100px]') }) + it('uses the full-bleed split layout for login and registration', () => { + for (const pathname of ['/login', '/register']) { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: SPLIT_AUTH_MAIN_CLASS_NAME, + contentClassName: '', + }) + } + }) + + it('keeps the compact entry layout for password reset', () => { + for (const pathname of ['/reset-password']) { + expect(getAppMainContentLayout(pathname)).toEqual({ + mainClassName: AUTH_MAIN_CLASS_NAME, + contentClassName: '', + }) + } + }) + it('leaves other non-landing routes on the default full-width app content layout', () => { expect(getAppMainContentLayout('/space/acme/demo')).toEqual({ mainClassName: DEFAULT_MAIN_CLASS_NAME, diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts index e6ad95f4..e6205406 100644 --- a/web/src/app/layout-main-content.ts +++ b/web/src/app/layout-main-content.ts @@ -1,10 +1,14 @@ export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10' +export const SPLIT_AUTH_MAIN_CLASS_NAME = 'flex-1 relative z-10' +export const AUTH_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 pb-4 sm:px-6 md:px-8' export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-12 md:py-10' export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto min-h-[calc(100vh-11rem)] w-full max-w-[1100px]' export const DASHBOARD_PATH_PREFIXES = ['/dashboard', '/settings/'] as const +export const AUTH_ENTRY_PATHS = ['/login', '/register', '/reset-password'] as const +export const SPLIT_AUTH_PATHS = ['/login', '/register'] as const interface AppMainContentLayout { mainClassName: string @@ -26,6 +30,20 @@ export function getAppMainContentLayout(pathname: string): AppMainContentLayout } } + if (SPLIT_AUTH_PATHS.includes(pathname as typeof SPLIT_AUTH_PATHS[number])) { + return { + mainClassName: SPLIT_AUTH_MAIN_CLASS_NAME, + contentClassName: '', + } + } + + if (AUTH_ENTRY_PATHS.includes(pathname as typeof AUTH_ENTRY_PATHS[number])) { + return { + mainClassName: AUTH_MAIN_CLASS_NAME, + contentClassName: '', + } + } + if (pathname === '/search') { return { mainClassName: CENTERED_MAIN_CLASS_NAME, diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 164582d2..1981c41a 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -13,7 +13,7 @@ import { syncDocumentLanguage } from '@/shared/lib/document-language' import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { getAppHeaderClassName } from './layout-header-style' -import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' +import { AUTH_ENTRY_PATHS, SPLIT_AUTH_PATHS, getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' const FOOTER_LINK_CLASS_NAME = 'group relative inline-flex py-0.5 transition-colors duration-150 hover:text-foreground focus-visible:outline-none focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-secondary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:bg-foreground/60 after:transition-transform after:duration-200 hover:after:scale-x-100 motion-reduce:after:transition-none' @@ -37,6 +37,8 @@ export function Layout() { const previousPathnameRef = useRef(pathname) const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + const isAuthEntryRoute = AUTH_ENTRY_PATHS.includes(contentLayoutPathname as typeof AUTH_ENTRY_PATHS[number]) + const isSplitAuthRoute = SPLIT_AUTH_PATHS.includes(contentLayoutPathname as typeof SPLIT_AUTH_PATHS[number]) const isDashboardSubRoute = pathname !== '/dashboard' && pathname.startsWith('/dashboard') const showSidebar = (isDashboardSubRoute && pathname !== '/dashboard/publish') || pathname.startsWith('/settings/') const governanceVisible = canViewGovernanceCenter(user?.platformRoles) @@ -113,63 +115,72 @@ export function Layout() { {/* Header */} -
- - SkillHub - +
+ {!isSplitAuthRoute ? ( + + SkillHub + + ) : null} {/* Desktop nav — lg+ only */} - + ) : null}
{/* Hamburger — visible below lg */} - + {!isAuthEntryRoute ? ( + + ) : null} {user && } {isLoading ? null : user ? ( - ) : ( + ) : !isAuthEntryRoute ? ( {t('nav.login')} - )} + ) : null}
{/* Mobile nav dropdown */} - {mobileMenuOpen ? ( + {mobileMenuOpen && !isAuthEntryRoute ? (
) } diff --git a/web/src/features/auth/auth-shell.test.tsx b/web/src/features/auth/auth-shell.test.tsx new file mode 100644 index 00000000..0e4ea4d7 --- /dev/null +++ b/web/src/features/auth/auth-shell.test.tsx @@ -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(
Login form
) + + expect(html).toContain('dark:bg-slate-950') + expect(html).toContain('sticky top-0 hidden h-svh') + expect(html).toContain('authShell.heroTitle') + expect(html).toContain('login-skill-art-light.png') + expect(html).toContain('login-skill-art-dark.png') + expect(html).toContain('authShell.backHome') + expect(html).toContain('xl:hidden') + expect(html).toContain('pt-20') + expect(html).toContain('Login form') + }) +}) diff --git a/web/src/features/auth/auth-shell.tsx b/web/src/features/auth/auth-shell.tsx new file mode 100644 index 00000000..308d2d71 --- /dev/null +++ b/web/src/features/auth/auth-shell.tsx @@ -0,0 +1,62 @@ +import type React from 'react' +import { ArrowLeft } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { BrandMark } from '@/shared/components/brand-mark' +import { withBasePath } from '@/shared/lib/base-path' +import { cn } from '@/shared/lib/utils' + +interface AuthShellProps { + children: React.ReactNode + className?: string +} + +/** Shared theme-aware product introduction and form layout for login and registration. */ +export function AuthShell({ children, className }: AuthShellProps) { + const { t } = useTranslation() + + return ( +
+ + +
+ + +
+
+ {children} +
+
+
+
+ ) +} diff --git a/web/src/features/auth/enterprise-discovery-entry.test.tsx b/web/src/features/auth/enterprise-discovery-entry.test.tsx new file mode 100644 index 00000000..fb917a4c --- /dev/null +++ b/web/src/features/auth/enterprise-discovery-entry.test.tsx @@ -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( + + + , + ) +} + +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()) + }) +}) diff --git a/web/src/features/auth/enterprise-discovery-entry.tsx b/web/src/features/auth/enterprise-discovery-entry.tsx new file mode 100644 index 00000000..7f033ed1 --- /dev/null +++ b/web/src/features/auth/enterprise-discovery-entry.tsx @@ -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 ( +
+
{ + event.preventDefault() + const value = identifier.trim() + if (value) discovery.mutate(value) + }} + > +
+ +
+
+
+

{t('login.organizationLoginHint')}

+ +
+ + {discovery.isError ?

{discovery.error.message}

: null} + {discovery.data && discovery.data.organizations.length === 0 ? ( +

{t('login.organizationNotFound')}

+ ) : null} + {discovery.data?.organizations.map((organization) => ( +
+

{organization.displayName}

+ {organization.loginOptions.map((option) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/web/src/features/auth/login-button.test.tsx b/web/src/features/auth/login-button.test.tsx new file mode 100644 index 00000000..a78d0673 --- /dev/null +++ b/web/src/features/auth/login-button.test.tsx @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AuthMethod } from '@/api/types' +import { AuthMethodButtonList } from './login-button' + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string, values?: Record) => ( + key === 'loginButton.loginWith' && values?.name ? `Login with ${values.name}` : key + ), + }), + } +}) + +vi.mock('@/shared/lib/base-path', () => ({ + BASE_PATH: '/', + withBasePath: (path: string) => path, +})) + +vi.mock('./use-auth-methods', () => ({ + useAuthMethods: () => ({ data: [], isLoading: false }), +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})) + +const methods: AuthMethod[] = [ + { + id: 'github', + methodType: 'OAUTH_REDIRECT', + provider: 'github', + displayName: 'GitHub', + actionUrl: '/oauth2/authorization/github', + }, + { + id: 'feishu', + methodType: 'OAUTH_REDIRECT', + provider: 'feishu', + displayName: 'Feishu', + actionUrl: '/oauth2/authorization/feishu', + }, + { + id: 'password', + methodType: 'PASSWORD', + provider: 'local', + displayName: 'Password', + actionUrl: '/login', + }, +] + +describe('AuthMethodButtonList', () => { + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('renders OAuth redirect methods from backend configuration only', () => { + render() + + expect(screen.getByText('Login with GitHub')).toBeTruthy() + expect(screen.getByText('Login with Feishu')).toBeTruthy() + expect(screen.queryByText('Login with Password')).toBeNull() + }) + + it('renders the supplied empty fallback when no OAuth methods are available', () => { + render( method.methodType !== 'OAUTH_REDIRECT')} emptyFallback={

No methods

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

{manualError}

+ ) : null} +
+ ) + } + return (
diff --git a/web/src/features/auth/use-auth-methods.test.ts b/web/src/features/auth/use-auth-methods.test.ts index f353b95c..7e15e10e 100644 --- a/web/src/features/auth/use-auth-methods.test.ts +++ b/web/src/features/auth/use-auth-methods.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getAuthMethodsQueryOptions, useAuthMethods } from './use-auth-methods' +const getMethods = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/client', () => ({ authApi: { getMethods } })) + describe('getAuthMethodsQueryOptions', () => { it('keeps login auth-method lookup local to the page and bypasses the global 401 redirect', () => { const options = getAuthMethodsQueryOptions('/dashboard') @@ -10,6 +14,12 @@ describe('getAuthMethodsQueryOptions', () => { expect(options.meta).toEqual({ skipGlobalErrorHandler: true }) expect(options.queryFn).toBeTypeOf('function') }) + + it('does not invent provider buttons when the server is unavailable', async () => { + getMethods.mockRejectedValueOnce(new Error('backend unavailable')) + + await expect(getAuthMethodsQueryOptions().queryFn()).rejects.toThrow('backend unavailable') + }) }) describe('use-auth-methods module exports', () => { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3e4333f4..4f782ca6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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" }, diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f8b28c2f..279b5eb6 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Заморожено", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index d0021770..e9b9705a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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}} 星" }, diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx index dacfa477..f80a698b 100644 --- a/web/src/pages/login.test.tsx +++ b/web/src/pages/login.test.tsx @@ -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: () =>

organization discovery

, })) vi.mock('@/features/auth/login-button', () => ({ + AuthMethodButtonList: () => null, LoginButton: () => null, })) vi.mock('@/features/auth/session-bootstrap-entry', () => ({ - SessionBootstrapEntry: () => null, + SessionBootstrapEntry: () =>

session bootstrap

, })) vi.mock('@/features/auth/use-auth-methods', () => ({ - useAuthMethods: () => ({ data: [] }), + useAuthMethods: () => ({ data: authMethodsFixture.methods, isError: authMethodsFixture.isError }), })) vi.mock('@/features/auth/use-password-login', () => ({ @@ -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) => , })) vi.mock('@/shared/ui/input', () => ({ - Input: () => null, -})) - -vi.mock('@/shared/ui/tabs', () => ({ - Tabs: ({ children }: { children: unknown }) => children, - TabsContent: ({ children }: { children: unknown }) => children, - TabsList: ({ children }: { children: unknown }) => children, - TabsTrigger: ({ children }: { children: unknown }) => children, + Input: (props: React.InputHTMLAttributes) => , })) import { renderToStaticMarkup } from 'react-dom/server' import { LoginPage } from './login' describe('LoginPage', () => { + afterEach(() => { + cleanup() + authMethodsFixture.methods = [{ 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() + 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() + + 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() + + expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull() + expect(screen.queryByText('session bootstrap')).toBeNull() + }) + + it('preserves configured session bootstrap when the method catalog is unavailable', () => { + authMethodsFixture.methods = [] + authMethodsFixture.bootstrapEnabled = true + authMethodsFixture.isError = true + render() + + fireEvent.click(screen.getByRole('button', { name: 'login.tabEnterprise' })) + expect(screen.getByText('session bootstrap').closest('[hidden]')).toBeNull() }) }) diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 1aab99c4..9d7046e2 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,15 +1,16 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Eye, EyeOff } from 'lucide-react' -import { getDirectAuthRuntimeConfig } from '@/api/client' -import { LoginButton } from '@/features/auth/login-button' +import { ArrowRight, Eye, EyeOff, LockKeyhole, UserRound } from 'lucide-react' +import { 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) { event.preventDefault() @@ -64,132 +70,152 @@ export function LoginPage() { } return ( -
-
-
-
- S -
-

{t('login.title')}

-

+ +

+
+

{t('login.eyebrow')}

+

{t('login.title')}

+

{t('login.subtitle')}

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

+ {authMethodsError ? ( +

{t('login.methodsUnavailable')}

+ ) : null} + + {authMethodsLoading || hasExternalMethods ? ( +
+
+ {t('login.otherMethods')} +
+ +
+ ) : null} + +

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

+ +

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

-
+ ) } diff --git a/web/src/pages/register.test.tsx b/web/src/pages/register.test.tsx index 7395fb3d..e6b820ae 100644 --- a/web/src/pages/register.test.tsx +++ b/web/src/pages/register.test.tsx @@ -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, })) diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index f8a32d05..b580926d 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -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 ( -
- - - {t('register.title')} - {t('register.subtitle')} - - - - - {t('register.tabLocal')} - {t('register.tabOAuth')} - + +
+
+

{t('register.eyebrow')}

+

{t('register.title')}

+

{t('register.subtitle')}

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

{fieldErrors.username}

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

{fieldErrors.email}

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

{fieldErrors.password}

: null} -
- {formError ?

{formError}

: null} - -

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

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

{fieldErrors.username}

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

{fieldErrors.email}

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

{fieldErrors.password}

: null} +
+ {formError ?

{formError}

: null} + +

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

+
- -

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

- -
- - - -
+
+

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

+ +
+
+ ) } diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index 05a40f31..b43aaf8e 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -59,9 +59,10 @@ interface TabsTriggerProps { value: string children: React.ReactNode className?: string + disabled?: boolean } -export function TabsTrigger({ value, children, className }: TabsTriggerProps) { +export function TabsTrigger({ value, children, className, disabled = false }: TabsTriggerProps) { const context = React.useContext(TabsContext) if (!context) throw new Error('TabsTrigger must be used within Tabs') @@ -72,6 +73,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) { type="button" role="tab" aria-selected={isActive} + aria-disabled={disabled} + disabled={disabled} onClick={() => context.setValue(value)} data-state={isActive ? 'active' : 'inactive'} className={cn(