fix(auth): defer organization discovery until backend contract

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-24 09:48:56 +08:00
parent 5d4b40a5e5
commit 5a8b796d03
11 changed files with 62 additions and 211 deletions

View file

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
test.describe('Auth Entry (Real API)', () => {
test.describe('Auth Entry', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
})
@ -18,4 +18,54 @@ test.describe('Auth Entry (Real API)', () => {
await page.getByRole('link', { name: 'Sign up now' }).click()
await expect(page).toHaveURL('/register?returnTo=%2Fdashboard%2Ftokens')
})
test('shows configured OAuth methods without exposing unsupported organization discovery', async ({ page }) => {
await page.route('**/api/v1/auth/methods*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
msg: 'ok',
data: [
{
id: 'github',
methodType: 'OAUTH_REDIRECT',
provider: 'github',
displayName: 'GitHub',
actionUrl: '/oauth2/authorization/github',
},
{
id: 'enterprise-discovery',
methodType: 'ENTERPRISE_DISCOVERY',
provider: 'enterprise',
displayName: 'Organization login',
actionUrl: '/api/v1/auth/login-discovery',
},
],
}),
})
})
await page.goto('/login')
await expect(page.getByRole('button', { name: 'GitHub' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Organization login' })).toHaveCount(0)
})
test('keeps configured session bootstrap available in the organization view', async ({ page }) => {
await page.route('**/runtime-config.js', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: 'window.__SKILLHUB_RUNTIME_CONFIG__ = { authSessionBootstrapEnabled: "true", authSessionBootstrapProvider: "proxy", authSessionBootstrapAuto: "false" }',
})
})
await page.goto('/login')
await page.getByRole('button', { name: 'Organization login' }).click()
await expect(page.getByRole('button', { name: 'Log in with work account' })).toBeVisible()
await expect(page.getByLabel('Password', { exact: true })).toBeHidden()
})
})

View file

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

View file

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

View file

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

View file

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

View file

@ -52,7 +52,7 @@ export function SessionBootstrapEntry({ onAuthenticated, methodDisplayName, comp
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"
className="h-11 w-full rounded-xl bg-[#315b86] text-white hover:bg-[#25496d] dark:bg-sky-300 dark:text-sky-950 dark:hover:bg-sky-200 xl:h-10"
type="button"
disabled={bootstrapMutation.isPending}
onClick={() => {

View file

@ -342,12 +342,6 @@
"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.",
"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",

View file

@ -342,12 +342,6 @@
"personalHint": "Используйте имя пользователя и пароль SkillHub.",
"otherMethods": "Или войдите через",
"orgLoginHint": "Для организаций с настроенным единым входом",
"organizationLogin": "Войти через организацию",
"organizationLoginHint": "Введите код организации или рабочую почту, чтобы найти доступные способы входа.",
"organizationIdentifier": "Код организации или рабочая почта",
"organizationContinue": "Найти способы входа",
"organizationSearching": "Поиск...",
"organizationNotFound": "Способ входа для организации не найден. Проверьте данные или обратитесь к администратору.",
"externalIdentityMethods": "Внешние источники идентификации",
"externalIdentityHint": "Feishu, DingTalk, GitHub, GitLab и похожие входы публикуются сервером. Принадлежность аккаунта определяют привязки и членство в организациях на backend.",
"tabPassword": "Пароль",

View file

@ -342,12 +342,6 @@
"personalHint": "使用 SkillHub 用户名和密码登录。",
"otherMethods": "或使用以下方式登录",
"orgLoginHint": "适用于已配置企业单点登录的组织",
"organizationLogin": "通过组织登录",
"organizationLoginHint": "输入组织标识或工作邮箱,我们会查找该组织已开通的登录方式。",
"organizationIdentifier": "组织标识或工作邮箱",
"organizationContinue": "查找登录方式",
"organizationSearching": "查找中...",
"organizationNotFound": "未找到可用的组织登录方式。请核对信息,或联系组织管理员。",
"externalIdentityMethods": "外部身份源",
"externalIdentityHint": "飞书、钉钉、GitHub、GitLab 等入口由服务端配置;登录后的账号归属由后端绑定和组织关系决定。",
"tabPassword": "账号密码",

View file

@ -4,7 +4,7 @@ import { afterEach } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
const authMethodsFixture = vi.hoisted(() => ({
methods: [{ id: 'organization', methodType: 'ENTERPRISE_DISCOVERY' }],
methods: [] as Array<{ id: string, methodType: string }>,
bootstrapEnabled: false,
isError: false,
returnTo: '',
@ -46,10 +46,6 @@ 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,
@ -85,7 +81,7 @@ import { LoginPage } from './login'
describe('LoginPage', () => {
afterEach(() => {
cleanup()
authMethodsFixture.methods = [{ id: 'organization', methodType: 'ENTERPRISE_DISCOVERY' }]
authMethodsFixture.methods = []
authMethodsFixture.bootstrapEnabled = false
authMethodsFixture.isError = false
authMethodsFixture.returnTo = ''
@ -103,8 +99,7 @@ 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).not.toContain('login.tabEnterprise')
expect(html).toContain('login.register')
})
@ -125,7 +120,8 @@ describe('LoginPage', () => {
await waitFor(() => expect(authMethodsFixture.navigate).toHaveBeenCalledWith({ to: '/skills?tab=mine' }))
})
it('replaces the password form with organization discovery and can switch back', () => {
it('replaces the password form with configured session bootstrap and can switch back', () => {
authMethodsFixture.bootstrapEnabled = true
render(<LoginPage />)
const personal = screen.getByRole('button', { name: 'login.tabPersonal' })
const organization = screen.getByRole('button', { name: 'login.tabEnterprise' })
@ -136,15 +132,14 @@ describe('LoginPage', () => {
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()
expect(screen.getByText('session bootstrap').closest('[hidden]')).toBeNull()
fireEvent.click(personal)
expect(screen.getByLabelText('login.password').closest('[hidden]')).toBeNull()
expect(screen.getByText('organization discovery').closest('[hidden]')).not.toBeNull()
expect(screen.getByText('session bootstrap').closest('[hidden]')).not.toBeNull()
})
it('does not show an organization entry when the server does not advertise one', () => {
authMethodsFixture.methods = []
it('does not show an organization entry when bootstrap is not configured', () => {
render(<LoginPage />)
expect(screen.queryByRole('button', { name: 'login.tabEnterprise' })).toBeNull()

View file

@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
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'
@ -40,8 +39,7 @@ 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 hasOrganizationMethod = bootstrapConfig.enabled
const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT')
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
@ -177,14 +175,8 @@ export function LoginPage() {
</form>
</div>
{discoveryMethod ? (
<div hidden={loginMode !== 'organization'}>
<EnterpriseDiscoveryEntry returnTo={returnTo} />
</div>
) : null}
{bootstrapConfig.enabled ? (
<div hidden={loginMode !== 'organization' || Boolean(discoveryMethod)} className="space-y-2">
<div hidden={loginMode !== 'organization'} className="space-y-2">
<SessionBootstrapEntry
methodDisplayName={bootstrapMethod?.displayName}
onAuthenticated={() => navigate({ to: returnTo })}