mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
fix(web): ISSUE-62 gate security settings by capability
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
665ee0499a
commit
54006e72a4
9 changed files with 371 additions and 106 deletions
102
web/e2e/settings-security-capability.spec.ts
Normal file
102
web/e2e/settings-security-capability.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
interface MockSessionUser {
|
||||
userId: string
|
||||
displayName: string
|
||||
email: string
|
||||
avatarUrl: string
|
||||
oauthProvider: string
|
||||
canChangePassword: boolean
|
||||
platformRoles: string[]
|
||||
}
|
||||
|
||||
function apiEnvelope(data: unknown) {
|
||||
return {
|
||||
code: 0,
|
||||
msg: 'OK',
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: 'e2e-security-capability',
|
||||
}
|
||||
}
|
||||
|
||||
async function mockSession(page: Page, user: MockSessionUser) {
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(apiEnvelope(user)),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/web/me/namespaces', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(apiEnvelope([])),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/web/notifications/unread-count', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(apiEnvelope({ count: 0 })),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/web/notifications/sse', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: ': ok\n\n',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Security Settings capability', () => {
|
||||
test('shows the security menu entry and password form for local admin accounts', async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await mockSession(page, {
|
||||
userId: 'local-admin',
|
||||
displayName: 'Local Admin',
|
||||
email: 'local-admin@example.test',
|
||||
avatarUrl: '',
|
||||
oauthProvider: '',
|
||||
canChangePassword: true,
|
||||
platformRoles: ['USER', 'SUPER_ADMIN'],
|
||||
})
|
||||
|
||||
await page.goto('/settings/security')
|
||||
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
|
||||
await expect(page.getByLabel('Current Password')).toBeVisible()
|
||||
await expect(page.getByLabel('New Password')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Local Admin' }).click()
|
||||
await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides the security menu entry and form when password changes are unavailable', async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await mockSession(page, {
|
||||
userId: 'oauth-only-user',
|
||||
displayName: 'OAuth Only User',
|
||||
email: 'oauth-only@example.test',
|
||||
avatarUrl: '',
|
||||
oauthProvider: 'github',
|
||||
canChangePassword: false,
|
||||
platformRoles: ['USER'],
|
||||
})
|
||||
|
||||
await page.goto('/settings/security')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
|
||||
await expect(page.getByText('Password changes are unavailable for this account.')).toBeVisible()
|
||||
await expect(page.getByLabel('Current Password')).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'OAuth Only User' }).click()
|
||||
await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -743,6 +743,8 @@
|
|||
"successTitle": "Password changed successfully",
|
||||
"successDescription": "Please sign in again with your new password.",
|
||||
"defaultError": "Failed to change password",
|
||||
"unavailableTitle": "Password changes are unavailable for this account.",
|
||||
"unavailableDescription": "This account signs in through an external identity provider or has no local password credential.",
|
||||
"submitting": "Submitting...",
|
||||
"submit": "Update Password"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -743,6 +743,8 @@
|
|||
"successTitle": "密码修改成功",
|
||||
"successDescription": "请使用新密码重新登录。",
|
||||
"defaultError": "修改密码失败",
|
||||
"unavailableTitle": "此账号暂不可修改密码。",
|
||||
"unavailableDescription": "此账号通过外部身份提供方登录,或尚未配置本地密码凭据。",
|
||||
"submitting": "提交中...",
|
||||
"submit": "更新密码"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status?: number
|
||||
},
|
||||
authApi: {
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: () => null,
|
||||
}))
|
||||
|
||||
import { SecuritySettingsPage } from './security'
|
||||
|
||||
describe('SecuritySettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof SecuritySettingsPage).toBe('function')
|
||||
})
|
||||
})
|
||||
119
web/src/pages/settings/security.test.tsx
Normal file
119
web/src/pages/settings/security.test.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { InputHTMLAttributes, ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
ApiError: class ApiError extends Error {
|
||||
status?: number
|
||||
},
|
||||
authApi: {
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: useAuthMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/error-display', () => ({
|
||||
truncateErrorMessage: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
type,
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: ReactNode }) => children,
|
||||
CardContent: ({ children }: { children: ReactNode }) => children,
|
||||
CardDescription: ({ children }: { children: ReactNode }) => children,
|
||||
CardHeader: ({ children }: { children: ReactNode }) => children,
|
||||
CardTitle: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/input', () => ({
|
||||
Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
}))
|
||||
|
||||
import { SecuritySettingsPage } from './security'
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: {
|
||||
userId: 'user-1',
|
||||
displayName: 'Local User',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecuritySettingsPage', () => {
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof SecuritySettingsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the password form when password changes are allowed', () => {
|
||||
const html = renderToStaticMarkup(<SecuritySettingsPage />)
|
||||
|
||||
expect(html).toContain('security.currentPassword')
|
||||
expect(html).toContain('security.newPassword')
|
||||
expect(html).toContain('security.submit')
|
||||
})
|
||||
|
||||
it('renders a read-only unavailable state when password changes are not allowed', () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
user: {
|
||||
userId: 'oauth-user',
|
||||
displayName: 'OAuth User',
|
||||
oauthProvider: 'github',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: false,
|
||||
},
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(<SecuritySettingsPage />)
|
||||
|
||||
expect(html).toContain('security.unavailableTitle')
|
||||
expect(html).toContain('security.unavailableDescription')
|
||||
expect(html).not.toContain('security.currentPassword')
|
||||
expect(html).not.toContain('security.submit')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'
|
|||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiError, authApi } from '@/api/client'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
|
||||
import { truncateErrorMessage } from '@/shared/lib/error-display'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
|
@ -19,10 +20,12 @@ export function SecuritySettingsPage() {
|
|||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const passwordChangeUnavailable = user?.canChangePassword === false
|
||||
|
||||
/**
|
||||
* Submits the password change request and clears local auth state afterward,
|
||||
|
|
@ -78,32 +81,39 @@ export function SecuritySettingsPage() {
|
|||
<CardDescription>{t('security.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="current-password">{t('security.currentPassword')}</label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
{passwordChangeUnavailable ? (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/30 p-4">
|
||||
<p className="text-sm font-medium text-foreground">{t('security.unavailableTitle')}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t('security.unavailableDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-password">{t('security.newPassword')}</label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('security.submitting') : t('security.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="current-password">{t('security.currentPassword')}</label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-password">{t('security.newPassword')}</label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errorMessage ? <p className="text-sm text-red-600">{errorMessage}</p> : null}
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('security.submitting') : t('security.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import * as mod from './user-menu'
|
||||
|
||||
/**
|
||||
* UserMenu is a React component that renders a hover/click dropdown menu with
|
||||
* role-based navigation links (dashboard, reviews, admin, etc.) and logout.
|
||||
* Internal helpers (hasRole, closeMenu, handleMouseEnter/Leave) and the
|
||||
* menuItemClassName constant are scoped inside the component function.
|
||||
* There are no exported pure helpers or constants to test here.
|
||||
*
|
||||
* We verify the module shape so downstream consumers break fast
|
||||
* if the export contract changes.
|
||||
*/
|
||||
describe('user-menu module exports', () => {
|
||||
it('exports the UserMenu component', () => {
|
||||
expect(mod.UserMenu).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
108
web/src/shared/components/user-menu.test.tsx
Normal file
108
web/src/shared/components/user-menu.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import * as mod from './user-menu'
|
||||
import { UserMenu } from './user-menu'
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useState: (initialValue: unknown) => [
|
||||
typeof initialValue === 'boolean' ? true : initialValue,
|
||||
vi.fn(),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
to: string
|
||||
}) => (
|
||||
<a
|
||||
href={to}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
onClick?.()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
setQueryData: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
authApi: {
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [] }),
|
||||
}))
|
||||
|
||||
/**
|
||||
* UserMenu is a React component that renders a hover/click dropdown menu with
|
||||
* role-based navigation links (dashboard, reviews, admin, etc.) and logout.
|
||||
*/
|
||||
describe('user-menu module exports', () => {
|
||||
it('exports the UserMenu component', () => {
|
||||
expect(mod.UserMenu).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserMenu security settings visibility', () => {
|
||||
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'OAuth Linked User',
|
||||
oauthProvider: 'github',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: true,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).toContain('user.menu.security')
|
||||
})
|
||||
|
||||
it('hides security settings when password changes are not allowed, even for a local-looking account', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'Local User',
|
||||
platformRoles: ['USER'],
|
||||
canChangePassword: false,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(html).not.toContain('user.menu.security')
|
||||
})
|
||||
})
|
||||
|
|
@ -14,6 +14,7 @@ interface User {
|
|||
avatarUrl?: string
|
||||
platformRoles?: string[]
|
||||
oauthProvider?: string
|
||||
canChangePassword?: boolean
|
||||
}
|
||||
|
||||
interface UserMenuProps {
|
||||
|
|
@ -37,7 +38,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
|
||||
const isSuperAdmin = hasRole('SUPER_ADMIN')
|
||||
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
|
||||
const isLocalAccount = !user.oauthProvider
|
||||
const canChangePassword = user.canChangePassword === true
|
||||
const open = isHovered || isClickOpen
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
|
|
@ -200,7 +201,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
<Link to="/settings/notifications" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.notifications')}
|
||||
</Link>
|
||||
{isLocalAccount ? (
|
||||
{canChangePassword ? (
|
||||
<Link to="/settings/security" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.security')}
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue