mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
fix(auth): defer organization discovery until backend contract
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
5d4b40a5e5
commit
5a8b796d03
11 changed files with 62 additions and 211 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -342,12 +342,6 @@
|
|||
"personalHint": "Используйте имя пользователя и пароль SkillHub.",
|
||||
"otherMethods": "Или войдите через",
|
||||
"orgLoginHint": "Для организаций с настроенным единым входом",
|
||||
"organizationLogin": "Войти через организацию",
|
||||
"organizationLoginHint": "Введите код организации или рабочую почту, чтобы найти доступные способы входа.",
|
||||
"organizationIdentifier": "Код организации или рабочая почта",
|
||||
"organizationContinue": "Найти способы входа",
|
||||
"organizationSearching": "Поиск...",
|
||||
"organizationNotFound": "Способ входа для организации не найден. Проверьте данные или обратитесь к администратору.",
|
||||
"externalIdentityMethods": "Внешние источники идентификации",
|
||||
"externalIdentityHint": "Feishu, DingTalk, GitHub, GitLab и похожие входы публикуются сервером. Принадлежность аккаунта определяют привязки и членство в организациях на backend.",
|
||||
"tabPassword": "Пароль",
|
||||
|
|
|
|||
|
|
@ -342,12 +342,6 @@
|
|||
"personalHint": "使用 SkillHub 用户名和密码登录。",
|
||||
"otherMethods": "或使用以下方式登录",
|
||||
"orgLoginHint": "适用于已配置企业单点登录的组织",
|
||||
"organizationLogin": "通过组织登录",
|
||||
"organizationLoginHint": "输入组织标识或工作邮箱,我们会查找该组织已开通的登录方式。",
|
||||
"organizationIdentifier": "组织标识或工作邮箱",
|
||||
"organizationContinue": "查找登录方式",
|
||||
"organizationSearching": "查找中...",
|
||||
"organizationNotFound": "未找到可用的组织登录方式。请核对信息,或联系组织管理员。",
|
||||
"externalIdentityMethods": "外部身份源",
|
||||
"externalIdentityHint": "飞书、钉钉、GitHub、GitLab 等入口由服务端配置;登录后的账号归属由后端绑定和组织关系决定。",
|
||||
"tabPassword": "账号密码",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue