fix(web): clarify pending avatars and space management

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-14 10:44:03 +08:00
parent be1529aeda
commit 5916771237
21 changed files with 573 additions and 168 deletions

View file

@ -1,6 +1,6 @@
# 技能头像与审核
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。
技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。
## 上传与提交

View file

@ -24,3 +24,9 @@
新分享地址为 `/join#invite=<token>`,兼容原 `/join#<token>`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。
命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。
### 2026-10-14 人工核验交互修正
返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。
邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。

View file

@ -37,7 +37,7 @@
Token SHALL 避免出现在日志、持久化明文和登录 URL 中。
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#invite=<token>`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=<token>`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。
Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;数据库仅保存 SHA-256 哈希。
#### Scenario: 经过登录回跳
@ -121,3 +121,10 @@ API SHALL 使用 Issue #911 的路径与响应合同。
- **WHEN** 他人已消费邀请,而管理员页面还缓存旧的可兑换状态
- **THEN** 点击生成先刷新服务端状态,已消费或过期的邀请不误弹替换确认,仍可兑换的邀请必须确认
- **AND** 状态刷新失败时不沿用旧缓存生成或替换,显示可重试错误
#### Scenario: 重开邀请弹窗与成员页布局
- **WHEN** 同一账号在同一标签页重开弹窗或刷新成员页
- **THEN** 重新校验有效的已保存链接后显示,并提供复制;失效或替换的链接不展示
- **AND** 无保存链接时说明限制,不自动替换邀请
- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示
- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分

View file

@ -202,3 +202,11 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使
- **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表
- **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录”
- **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限
### Requirement: 提交者 SHALL 可查看已提交头像
技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。
#### Scenario: 头像申请等待审核
- **WHEN** 所有者提交新头像并返回我的技能或重开更新页
- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像

View file

@ -7,16 +7,25 @@ const api = vi.hoisted(() => ({
invitationStatus: vi.fn(),
createInvitation: vi.fn(),
revokeInvitation: vi.fn(),
previewInvitation: vi.fn(),
}))
vi.mock('@/api/client', () => ({ namespaceApi: api }))
vi.mock('@/api/client', () => ({ namespaceApi: api, getAppBaseUrl: () => undefined }))
const auth = vi.hoisted(() => ({ user: { userId: 'owner' } }))
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth }))
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), info: vi.fn() } }))
vi.mock('@/i18n/config', () => ({ default: { exists: () => false } }))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
import { ApiError } from '@/shared/lib/api-error'
import { InvitationControls } from './invitation-controls'
describe('InvitationControls', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
window.sessionStorage.clear()
auth.user = { userId: 'owner' }
Object.values(api).forEach((mock) => mock.mockReset())
})
@ -78,4 +87,135 @@ describe('InvitationControls', () => {
expect(screen.queryByRole('dialog')).toBeNull()
})
const token = 'a'.repeat(43)
const storageKey = 'skillhub.namespace.issued-invitation:owner:team'
const validPreview = { namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z', outcome: 'ALREADY_MEMBER' }
function mount(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
}
it('keeps the generated link after dialog reopen and a new QueryClient (tab reload)', async () => {
api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true })
api.createInvitation.mockResolvedValue({ token, replaced: false })
api.previewInvitation.mockResolvedValue(validPreview)
const view = mount()
const button = screen.getByRole('button', { name: 'invitation.create' })
await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))
fireEvent.click(button)
await screen.findByRole('textbox')
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
view.unmount()
mount()
expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(`/join#invite=${token}`)
expect(api.createInvitation).toHaveBeenCalledTimes(1)
expect(api.previewInvitation).toHaveBeenCalledWith(token)
expect(screen.getByRole('button', { name: 'invitation.copy' })).toBeTruthy()
})
it.each(['consumed', 'expired', 'revoked'])('clears the remembered link when server reports %s/inactive', async () => {
window.sessionStorage.setItem(storageKey, token)
api.invitationStatus.mockResolvedValue({ active: false })
mount()
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull())
expect(screen.queryByRole('textbox')).toBeNull()
expect(api.previewInvitation).not.toHaveBeenCalled()
})
it('clears a replaced token and explains why the active link cannot be recovered', async () => {
window.sessionStorage.setItem(storageKey, token)
api.invitationStatus.mockResolvedValue({ active: true })
api.previewInvitation.mockRejectedValue(new ApiError('invalid', 404))
mount()
await screen.findByText('invitation.activeUnavailable')
expect(window.sessionStorage.getItem(storageKey)).toBeNull()
expect(screen.queryByRole('textbox')).toBeNull()
expect(api.createInvitation).not.toHaveBeenCalled()
})
it('hides remembered links on validation network failure but retains them for retry', async () => {
window.sessionStorage.setItem(storageKey, token)
api.invitationStatus.mockResolvedValue({ active: true })
api.previewInvitation.mockRejectedValue(new ApiError('network', 0))
mount()
await screen.findByRole('alert')
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
expect(screen.queryByRole('textbox')).toBeNull()
})
it('does not display a token saved by a different authenticated user', async () => {
window.sessionStorage.setItem(storageKey, token)
auth.user = { userId: 'another-admin' }
api.invitationStatus.mockResolvedValue({ active: true })
mount()
await screen.findByText('invitation.activeUnavailable')
expect(screen.queryByRole('textbox')).toBeNull()
expect(api.previewInvitation).not.toHaveBeenCalled()
})
it('clears the token after confirmed revoke', async () => {
window.sessionStorage.setItem(storageKey, token)
api.invitationStatus.mockResolvedValue({ active: true })
api.previewInvitation.mockResolvedValue(validPreview)
api.revokeInvitation.mockImplementation(async () => { api.invitationStatus.mockResolvedValue({ active: false }) })
mount()
await screen.findByRole('textbox')
fireEvent.click(screen.getByRole('button', { name: 'invitation.revoke' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'dialog.confirm' }))
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBeNull())
await waitFor(() => expect(screen.queryByRole('textbox')).toBeNull())
})
it('keeps only an in-memory copy and explains the limitation when storage is unavailable', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage unavailable') })
api.invitationStatus.mockResolvedValueOnce({ active: false }).mockResolvedValueOnce({ active: false }).mockResolvedValue({ active: true })
api.createInvitation.mockResolvedValue({ token, replaced: false })
api.previewInvitation.mockResolvedValue(validPreview)
mount()
const button = screen.getByRole('button', { name: 'invitation.create' })
await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false))
fireEvent.click(button)
await screen.findByRole('textbox')
expect(screen.getByText('invitation.storageUnavailable')).toBeTruthy()
expect(window.sessionStorage.getItem(storageKey)).toBeNull()
})
it('does not show a previously validated cached link while reopening validation fails', async () => {
window.sessionStorage.setItem(storageKey, token)
api.invitationStatus.mockResolvedValue({ active: true })
api.previewInvitation.mockResolvedValue(validPreview)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = mount(client)
await screen.findByRole('textbox')
view.unmount()
api.previewInvitation.mockRejectedValue(new ApiError('network', 0))
mount(client)
expect(screen.queryByRole('textbox')).toBeNull()
await screen.findByRole('alert')
expect(screen.queryByRole('textbox')).toBeNull()
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
})
it('replaces the remembered link only after confirmation and restores the new link on reopen', async () => {
window.sessionStorage.setItem(storageKey, token)
const nextToken = 'b'.repeat(43)
api.invitationStatus.mockResolvedValue({ active: true })
api.previewInvitation.mockResolvedValue(validPreview)
api.createInvitation.mockResolvedValue({ token: nextToken, replaced: true })
const view = mount()
await screen.findByRole('textbox')
fireEvent.click(screen.getByRole('button', { name: 'invitation.create' }))
const dialog = await screen.findByRole('dialog')
expect(api.createInvitation).not.toHaveBeenCalled()
expect(window.sessionStorage.getItem(storageKey)).toBe(token)
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' }))
await waitFor(() => expect(window.sessionStorage.getItem(storageKey)).toBe(nextToken))
await waitFor(() => expect((screen.getByRole('textbox') as HTMLInputElement).value).toContain(nextToken))
expect(api.createInvitation).toHaveBeenCalledWith('team', true)
view.unmount()
mount()
expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(nextToken)
expect(api.createInvitation).toHaveBeenCalledTimes(1)
})
})

View file

@ -1,7 +1,9 @@
import { useId, useState } from 'react'
import { useId, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { getAppBaseUrl, namespaceApi } from '@/api/client'
import { useAuth } from '@/features/auth/use-auth'
import { ApiError } from '@/shared/lib/api-error'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { BASE_PATH } from '@/shared/lib/base-path'
import { invitationUrl } from '@/shared/lib/namespace-invitation'
@ -14,28 +16,65 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
const { t } = useTranslation()
const linkId = useId()
const queryClient = useQueryClient()
const { user } = useAuth()
const storageKey = `skillhub.namespace.issued-invitation:${user?.userId ?? ''}:${slug}`
const inMemoryToken = useRef<{ key: string; token: string } | null>(null)
const [storageUnavailable, setStorageUnavailable] = useState(false)
const [confirm, setConfirm] = useState<'replace' | 'revoke' | null>(null)
const [link, setLink] = useState<string | null>(null)
const status = useQuery({ queryKey: ['namespace-invitation-status', slug], queryFn: () => namespaceApi.invitationStatus(slug), enabled: !compact })
const clearStoredToken = () => {
inMemoryToken.current = null
try { window.sessionStorage.removeItem(storageKey) } catch { /* Storage may be unavailable. */ }
}
const status = useQuery({
queryKey: ['namespace-invitation-status', slug, user?.userId],
enabled: !compact && !!user,
staleTime: 0,
queryFn: async () => {
const current = await namespaceApi.invitationStatus(slug)
if (!current.active) {
clearStoredToken()
return { active: false, token: null }
}
let token = inMemoryToken.current?.key === storageKey ? inMemoryToken.current.token : null
try { token = window.sessionStorage.getItem(storageKey) ?? token } catch { /* Use the current dialog's copy. */ }
if (!token) return { active: true, token: null }
try {
const preview = await namespaceApi.previewInvitation(token)
if (preview.namespaceSlug !== slug || Date.parse(preview.expiresAt) <= Date.now()) {
clearStoredToken()
return { active: true, token: null }
}
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 404) throw error
clearStoredToken()
return { active: true, token: null }
}
return { active: true, token }
},
})
const link = !status.isFetching && !status.isError && status.data?.token
? invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), status.data.token)
: null
const copyLink = async (url: string, replaced = false) => {
try {
await navigator.clipboard.writeText(url)
toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied'))
} catch { toast.info(t('invitation.manualCopy')) }
}
const create = useMutation({
mutationFn: (replace: boolean) => namespaceApi.createInvitation(slug, replace),
onSuccess: ({ token, replaced }) => {
onSuccess: async ({ token, replaced }) => {
inMemoryToken.current = { key: storageKey, token }
try { window.sessionStorage.setItem(storageKey, token) } catch { setStorageUnavailable(true) }
const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token)
setLink(url)
try {
void navigator.clipboard.writeText(url)
.then(() => toast.success(t(replaced ? 'invitation.replacedCopied' : 'invitation.copied')))
.catch(() => toast.info(t('invitation.manualCopy')))
} catch {
toast.info(t('invitation.manualCopy'))
}
void queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
await copyLink(url, replaced)
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
},
})
const revoke = useMutation({
mutationFn: () => namespaceApi.revokeInvitation(slug),
onSuccess: async () => {
setLink(null)
clearStoredToken()
await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] })
toast.success(t('invitation.revoked'))
},
@ -50,16 +89,20 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co
return (
<div id={compact ? undefined : 'invite'} className="space-y-2 scroll-mt-24" onClick={compact ? (event) => event.stopPropagation() : undefined}>
<div className="flex gap-2">
<Button variant="outline" size={compact ? 'sm' : 'default'} disabled={(!compact && status.isPending) || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
<Button variant="outline" size={compact ? 'sm' : 'default'} disabled={!user || (!compact && status.isPending) || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>
{status.data?.active ? <Button variant="outline" disabled={revoke.isPending} onClick={() => setConfirm('revoke')}>{t('invitation.revoke')}</Button> : null}
</div>
{link ? (
<div className="space-y-1">
<label className="text-sm" htmlFor={linkId}>{t('invitation.link')}</label>
<Input id={linkId} readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
<p className="text-xs text-muted-foreground">{t('invitation.linkHint')}</p>
<div className="flex gap-2">
<Input id={linkId} readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
<Button variant="outline" onClick={() => void copyLink(link)}>{t('invitation.copy')}</Button>
</div>
<p className="text-xs text-muted-foreground">{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}</p>
</div>
) : null}
{!link && !status.isFetching && !status.isError && status.data?.active ? <p className="text-sm text-muted-foreground">{t('invitation.activeUnavailable')}</p> : null}
{(create.error || revoke.error || status.error) ? <p role="alert" className="text-sm text-destructive">{t('invitation.error')}</p> : null}
<ConfirmDialog open={confirm !== null} onOpenChange={(open) => { if (!open) setConfirm(null) }}
title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')}

View file

@ -1,9 +1,20 @@
import { ArrowLeft } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
import { cn } from '@/shared/lib/utils'
export function NamespaceManagementBack() {
const { t } = useTranslation()
return (
<Link to="/dashboard/namespaces" className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{t('namespaceManagement.back')}
</Link>
)
}
export function NamespaceManagementNav({ slug, active }: { slug: string; active: 'skills' | 'members' | 'reviews' | 'settings' }) {
const { t } = useTranslation()
const { hasRole } = useAuth()
@ -19,9 +30,6 @@ export function NamespaceManagementNav({ slug, active }: { slug: string; active:
] as const
return (
<div className="space-y-3">
<Link to="/dashboard/namespaces" className="inline-flex text-sm text-muted-foreground hover:text-foreground">
{t('namespaceManagement.back')}
</Link>
<nav aria-label={t('namespaceManagement.navigation')} className="flex gap-1 overflow-x-auto border-b border-border">
{tabs.filter((tab) => tab.visible).map((tab) => (
<Link key={tab.key} to={tab.path} params={{ slug }}

View file

@ -47,6 +47,75 @@ describe('SkillAvatarManager appeal', () => {
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(false)
})
it('shows the submitted image separately from the unchanged public avatar while waiting', async () => {
api.status.mockResolvedValue({ avatarUrl: '/old-public', candidateId: 8, candidateStatus: 'PENDING',
machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
expect((await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).getAttribute('src')).toBe('/private-candidate/8')
expect(screen.getByText('skillAvatar.publicUnchanged')).toBeTruthy()
expect(screen.queryByRole('img')?.getAttribute('src')).not.toBe('/old-public')
expect(api.status).toHaveBeenCalledWith('team', 'foo')
})
it('keeps the My Skills preview read-only without adding another avatar action', async () => {
api.status.mockResolvedValue({ candidateId: 8, candidateStatus: 'PENDING', machineStatus: 'BLOCKED',
reviewStatus: 'NOT_REQUIRED', candidateUrl: '/private-candidate/8' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
it('explains an unavailable candidate preview rather than presenting the public avatar as the submission', async () => {
api.status.mockResolvedValue({ avatarUrl: '/old-public', candidateId: 8, candidateStatus: 'REJECTED',
machineStatus: 'SKIPPED', reviewStatus: 'REJECTED', candidateUrl: '/private-candidate/8', reviewReason: 'Please choose a different image' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
fireEvent.error(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' }))
expect(screen.getByText('skillAvatar.candidateUnavailable')).toBeTruthy()
expect(screen.getByText('Please choose a different image')).toBeTruthy()
expect(screen.queryByRole('img')).toBeNull()
})
it('removes the pending preview after the standard approval invalidates owner state', async () => {
api.status.mockResolvedValueOnce({ candidateId: 8, candidateStatus: 'PENDING', machineStatus: 'SKIPPED',
reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
.mockResolvedValue({ avatarUrl: '/new-public', candidateId: 8, candidateStatus: 'ACTIVE',
machineStatus: 'SKIPPED', reviewStatus: 'APPROVED', candidateUrl: '/private-candidate/8' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
await client.invalidateQueries({ queryKey: ['skill-avatar', 'team', 'foo'] })
await waitFor(() => expect(screen.queryByRole('img')).toBeNull())
expect(screen.queryByText('skillAvatar.publicUnchanged')).toBeNull()
})
it('reports an initial owner-status load failure and allows a read-only reload', async () => {
api.status.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue({ candidateId: 8,
candidateStatus: 'PENDING', machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
expect(await screen.findByText('skillAvatar.statusLoadFailed')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.reloadStatus' }))
expect(await screen.findByRole('img', { name: 'skillAvatar.submittedImage' })).toBeTruthy()
expect(screen.queryByRole('alert')).toBeNull()
expect(api.status).toHaveBeenCalledTimes(2)
})
it('keeps a cached submitted image visible while clearly reporting a failed status refresh', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(['skill-avatar', 'team', 'foo'], { candidateId: 8, candidateStatus: 'PENDING',
machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: '/private-candidate/8' })
api.status.mockRejectedValue(new Error('unavailable'))
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" previewOnly /></QueryClientProvider>)
expect(await screen.findByText('skillAvatar.statusRefreshFailed')).toBeTruthy()
expect(screen.getByRole('img', { name: 'skillAvatar.submittedImage' }).getAttribute('src')).toBe('/private-candidate/8')
expect(screen.getByRole('button', { name: 'skillAvatar.reloadStatus' })).toBeTruthy()
})
it('does not render approved reasons, duplicate image or editing controls', async () => {
api.status.mockResolvedValue({ avatarUrl: '/active', candidateId: 7, candidateStatus: 'ACTIVE', machineStatus: 'SKIPPED', reviewStatus: 'APPROVED', reviewReason: 'approved reason' })
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })

View file

@ -1,14 +1,14 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { skillAvatarApi } from '@/api/client'
import { buildApiUrl, skillAvatarApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
export function SkillAvatarManager({ namespace, slug, disabled = false }: { namespace: string; slug: string; disabled?: boolean }) {
export function SkillAvatarManager({ namespace, slug, disabled = false, previewOnly = false }: { namespace: string; slug: string; disabled?: boolean; previewOnly?: boolean }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const key = ['skill-avatar', namespace, slug]
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug })
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug, staleTime: 0 })
const [error, setError] = useState('')
const mutation = useMutation({
mutationFn: async (action: { kind: 'appeal' | 'retry'; id: number }) => action.kind === 'appeal'
@ -20,21 +20,36 @@ export function SkillAvatarManager({ namespace, slug, disabled = false }: { name
},
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})
const [failedPreview, setFailedPreview] = useState<string | null>(null)
const candidate = status.data
const retryCoolingDown = !!candidate?.machineNextRetryAt && Date.parse(candidate.machineNextRetryAt) > Date.now()
if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return null
const loadError = status.isError ? <div role="alert" className="flex flex-wrap items-center gap-2 text-xs text-destructive">
<span>{t(candidate ? 'skillAvatar.statusRefreshFailed' : 'skillAvatar.statusLoadFailed')}</span>
<Button type="button" size="sm" variant="ghost" disabled={status.isFetching} onClick={() => void status.refetch()}>{t('skillAvatar.reloadStatus')}</Button>
</div> : null
if (!candidate || !['PENDING', 'REJECTED'].includes(candidate.candidateStatus ?? '')) return loadError
return (
<div className="space-y-2 text-sm">
<div className={`space-y-2 rounded-lg border bg-muted/20 p-3 text-sm ${previewOnly ? 'mb-3' : ''}`} aria-label={t('skillAvatar.submittedImage')}>
<div className="flex items-center gap-3">
{candidate.candidateUrl && failedPreview !== candidate.candidateUrl
? <img src={buildApiUrl(candidate.candidateUrl)} alt={t('skillAvatar.submittedImage')} className="h-12 w-12 shrink-0 rounded-lg object-cover" onError={() => setFailedPreview(candidate.candidateUrl)} />
: <span className="text-xs text-muted-foreground">{t('skillAvatar.candidateUnavailable')}</span>}
<div className="min-w-0">
<p className="font-medium">{t('skillAvatar.submittedImage')}</p>
<p className="text-xs text-muted-foreground">{t('skillAvatar.publicUnchanged')}</p>
</div>
</div>
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)}</p>}
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}</p>}
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted && <p className="text-sm text-muted-foreground">{t('skillAvatar.automaticRetryStopped')}</p>}
<div className="flex flex-wrap gap-2">
{!previewOnly && <div className="flex flex-wrap gap-2">
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
&& candidate.reviewStatus === 'NOT_REQUIRED' && candidate.candidateId
&& <Button variant="outline" disabled={disabled || mutation.isPending} onClick={() => mutation.mutate({ kind: 'appeal', id: candidate.candidateId! })}>{t('skillAvatar.appeal')}</Button>}
{candidate?.machineStatus === 'ERROR' && candidate.candidateId && <Button variant="outline" disabled={disabled || mutation.isPending || retryCoolingDown} onClick={() => mutation.mutate({ kind: 'retry', id: candidate.candidateId! })}>{t('skillAvatar.retry')}</Button>}
</div>
</div>}
{loadError}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
)

View file

@ -1412,7 +1412,11 @@
"permissions": "Confirm to join as MEMBER. You can access space resources and publish skills.",
"alreadyMember": "You are already a member of this space.",
"enter": "Enter space",
"confirm": "Confirm and join"
"confirm": "Confirm and join",
"activeUnavailable": "The invitation is active, but this tab has no saved link. Copy it from the tab that created it, or confirm creating a replacement to invalidate the old link.",
"localLinkHint": "The link is saved only for this account in this browser tab; one new member can use it within 24 hours.",
"copy": "Copy link",
"storageUnavailable": "Your browser could not save the link. Copy it now; it cannot be restored after closing this dialog or refreshing."
},
"members": {
"title": "Member Management",
@ -1508,7 +1512,8 @@
"batchResultInvalidRole": "Invalid role",
"batchResultUnknownError": "Unknown error",
"batchDone": "Done",
"batchBack": "Back"
"batchBack": "Back",
"cancelRole": "Cancel role change"
},
"namespaceEdit": {
"editButton": "Edit",
@ -1550,7 +1555,8 @@
"nextPage": "Next",
"globalReadOnly": "This is a built-in system namespace. Review history is visible, but no new review actions are allowed.",
"frozenReadOnly": "This namespace is frozen. You can still view review history, but cannot continue processing review tasks.",
"archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored."
"archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored.",
"statusLabel": "Review status"
},
"namespaceStatus": {
"active": "Active",
@ -2693,7 +2699,13 @@
"cropConfirm": "Confirm and upload",
"change": "Change image",
"stagedReady": "Image uploaded; ready to submit",
"removedDraft": "Pending change: remove avatar"
"removedDraft": "Pending change: remove avatar",
"submittedImage": "Submitted avatar",
"publicUnchanged": "The public avatar stays unchanged until approval.",
"candidateUnavailable": "Submitted avatar preview unavailable",
"statusLoadFailed": "Couldn't load the submitted avatar. Please retry.",
"statusRefreshFailed": "Couldn't refresh review status. The last loaded avatar is shown.",
"reloadStatus": "Reload"
},
"namespaceManagement": {
"back": "Back to my spaces",

View file

@ -1477,7 +1477,11 @@
"permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.",
"alreadyMember": "Вы уже участник этого пространства.",
"enter": "Открыть пространство",
"confirm": "Подтвердить вступление"
"confirm": "Подтвердить вступление",
"activeUnavailable": "Приглашение активно, но ссылка не сохранена в этой вкладке. Скопируйте её из вкладки, в которой создали приглашение, или подтвердите замену ссылки.",
"localLinkHint": "Ссылка сохранена только для текущего аккаунта в этой вкладке; один новый участник может использовать её в течение 24 часов.",
"copy": "Копировать ссылку",
"storageUnavailable": "Браузер не смог сохранить ссылку. Скопируйте её сейчас; после закрытия окна или обновления восстановить её не получится."
},
"members": {
"title": "Управление участниками",
@ -1573,7 +1577,8 @@
"batchResultInvalidRole": "Недопустимая роль",
"batchResultUnknownError": "Неизвестная ошибка",
"batchDone": "Готово",
"batchBack": "Назад"
"batchBack": "Назад",
"cancelRole": "Отменить изменение роли"
},
"namespaceEdit": {
"editButton": "Редактировать",
@ -1770,7 +1775,8 @@
"nextPage": "Вперёд",
"globalReadOnly": "Это встроенное системное пространство имён. История ревью видна, но новые действия ревью недоступны.",
"frozenReadOnly": "Это пространство имён заморожено. Историю ревью можно смотреть, но обрабатывать задачи нельзя.",
"archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено."
"archivedReadOnly": "Это пространство имён в архиве. Историю ревью можно смотреть, но обрабатывать задачи нельзя, пока оно не восстановлено.",
"statusLabel": "Статус проверки"
},
"pagination": {
"label": "Навигация по страницам",
@ -2695,7 +2701,13 @@
"cropConfirm": "Подтвердить и загрузить",
"change": "Заменить изображение",
"stagedReady": "Изображение загружено; готово к отправке",
"removedDraft": "Изменение ожидает отправки: удалить аватар"
"removedDraft": "Изменение ожидает отправки: удалить аватар",
"submittedImage": "Отправленный аватар",
"publicUnchanged": "Публичный аватар не изменится до одобрения.",
"candidateUnavailable": "Предпросмотр отправленного аватара недоступен",
"statusLoadFailed": "Не удалось загрузить отправленный аватар. Повторите попытку.",
"statusRefreshFailed": "Не удалось обновить статус проверки. Показан последний загруженный аватар.",
"reloadStatus": "Загрузить повторно"
},
"namespaceManagement": {
"back": "Вернуться к моим пространствам",

View file

@ -1412,7 +1412,11 @@
"permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。",
"alreadyMember": "你已是该空间成员。",
"enter": "进入空间",
"confirm": "确认加入"
"confirm": "确认加入",
"activeUnavailable": "当前邀请仍有效,但此标签页没有保存完整链接。可在创建链接的标签页复制,或确认生成新链接使旧链接失效。",
"localLinkHint": "链接仅保存在当前账号的这个浏览器标签页;24 小时内可供一名新成员使用。",
"copy": "复制链接",
"storageUnavailable": "浏览器未能保存链接,请现在复制;关闭此窗口或刷新后将无法恢复。"
},
"members": {
"title": "成员管理",
@ -1508,7 +1512,8 @@
"batchResultInvalidRole": "角色无效",
"batchResultUnknownError": "未知错误",
"batchDone": "完成",
"batchBack": "返回"
"batchBack": "返回",
"cancelRole": "取消角色修改"
},
"namespaceEdit": {
"editButton": "编辑",
@ -1550,7 +1555,8 @@
"nextPage": "下一页",
"globalReadOnly": "这是系统内置命名空间,仅可查看审核记录,不允许执行新的审核动作。",
"frozenReadOnly": "命名空间已冻结。你仍可查看审核记录,但当前不能继续处理审核任务。",
"archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。"
"archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。",
"statusLabel": "审核状态"
},
"namespaceStatus": {
"active": "正常",
@ -2692,7 +2698,13 @@
"cropConfirm": "确认并上传",
"change": "更换图片",
"stagedReady": "图片已上传,待提交",
"removedDraft": "待提交:移除头像"
"removedDraft": "待提交:移除头像",
"submittedImage": "已提交头像",
"publicUnchanged": "审核通过前,公开展示的头像保持不变。",
"candidateUnavailable": "已提交头像暂时无法预览",
"statusLoadFailed": "无法加载已提交头像,请重试。",
"statusRefreshFailed": "无法刷新审核状态,以下为上次加载的头像。",
"reloadStatus": "重新加载"
},
"namespaceManagement": {
"back": "返回我的空间",

View file

@ -107,6 +107,11 @@ vi.mock('@/api/client', () => ({
},
}))
vi.mock('@/features/skill/skill-avatar-manager', () => ({
SkillAvatarManager: ({ namespace, slug, previewOnly }: { namespace: string; slug: string; previewOnly?: boolean }) =>
createElement('div', { 'data-submitted-avatar': `${namespace}/${slug}`, 'data-read-only': previewOnly }),
}))
import { MySkillsPage } from './my-skills'
function findButton(label: string) {
@ -172,6 +177,16 @@ describe('MySkillsPage', () => {
expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true)
})
it('offers an owner-only submitted avatar preview for active skills without a second editing action', () => {
useMySkillsMock.mockReturnValue({ data: { items: [{ id: 1, displayName: 'Team Agent', namespace: 'team-ai', slug: 'team-agent', avatarUrl: '/skill-avatar.png', status: 'ACTIVE', downloadCount: 0 }], total: 1 }, isLoading: false })
const html = renderToStaticMarkup(createElement(MySkillsPage))
expect(html).toContain('data-submitted-avatar="team-ai/team-agent"')
expect(html).toContain('data-read-only="true"')
expect(html).toContain('src="/skill-avatar.png"')
expect(buttonRecords.some(button => button.label === 'mySkills.update')).toBe(true)
expect(buttonRecords.some(button => button.label === 'skillAvatar.manage')).toBe(false)
})
it('does not render update action for archived skills', () => {
useMySkillsMock.mockReturnValue({
data: {

View file

@ -19,6 +19,7 @@ import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { buildReturnTo } from '@/shared/lib/auth-route'
import { SkillAvatar } from '@/features/skill/skill-avatar'
import { SkillAvatarManager } from '@/features/skill/skill-avatar-manager'
import { ApiError } from '@/api/client'
import { getMySkillEmptyStateKey, getMySkillFilters, type MySkillFilter } from './my-skill-filters'
@ -388,6 +389,7 @@ export function MySkillsPage() {
{skill.displayName}
</h3>
</div>
{skill.status === 'ACTIVE' && filter !== 'HIDDEN' && <SkillAvatarManager namespace={skill.namespace} slug={skill.slug} previewOnly />}
{skill.summary && (
<p className="text-sm text-muted-foreground mb-3 leading-relaxed">{skill.summary}</p>
)}

View file

@ -1,6 +1,6 @@
import { useParams } from '@tanstack/react-router'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
import { NamespaceSkills } from '@/features/namespace/namespace-skills'
@ -9,6 +9,7 @@ export function NamespaceManagementPage() {
const { data: namespace } = useNamespaceDetail(slug)
return (
<div className="space-y-6">
<NamespaceManagementBack />
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
<NamespaceManagementNav slug={slug} active="skills" />
<NamespaceSkills key={slug} namespace={slug} showHeader={false} />

View file

@ -1,72 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ slug: 'test-ns' }),
}))
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}
})
vi.mock('@/shared/lib/date-time', () => ({
formatLocalDateTime: (v: string) => v,
}))
vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({
AddNamespaceMemberDialog: () => null,
}))
vi.mock('@/features/namespace/namespace-header', () => ({
NamespaceHeader: () => null,
}))
vi.mock('@/shared/components/confirm-dialog', () => ({
ConfirmDialog: () => null,
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
DashboardPageHeader: () => null,
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
}))
vi.mock('@/shared/ui/select', () => ({
Select: ({ children }: { children: unknown }) => children,
SelectContent: ({ children }: { children: unknown }) => children,
SelectItem: ({ children }: { children: unknown }) => children,
SelectTrigger: ({ children }: { children: unknown }) => children,
SelectValue: () => null,
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [] }),
useNamespaceDetail: () => ({ data: null, isLoading: false }),
useNamespaceMembers: () => ({ data: [], isLoading: false, error: null }),
useRemoveNamespaceMember: () => ({ mutateAsync: vi.fn() }),
useUpdateNamespaceMemberRole: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/shared/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
import { NamespaceMembersPage } from './namespace-members'
describe('NamespaceMembersPage', () => {
it('exports a named component function', () => {
expect(typeof NamespaceMembersPage).toBe('function')
})
})

View file

@ -0,0 +1,93 @@
/** @vitest-environment jsdom */
import { Children, type ReactElement, type ReactNode, type Ref } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
const state = vi.hoisted(() => ({ role: 'OWNER', status: 'ACTIVE', update: vi.fn(), remove: vi.fn() }))
vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'team' }) }))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) }))
vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () => <h1>Team</h1> }))
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => <a href="/dashboard/namespaces">Back</a>, NamespaceManagementNav: () => <nav>Navigation</nav> }))
vi.mock('@/features/namespace/invitation-controls', () => ({ InvitationControls: () => null }))
vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({ AddNamespaceMemberDialog: ({ children }: { children: ReactNode }) => children }))
vi.mock('@/features/namespace/batch-import-members-dialog', () => ({ BatchImportMembersDialog: ({ children }: { children: ReactNode }) => children }))
vi.mock('@/features/namespace/transfer-ownership-dialog', () => ({ TransferOwnershipDialog: ({ children }: { children: ReactNode }) => children }))
vi.mock('@/shared/components/confirm-dialog', () => ({ ConfirmDialog: () => null }))
vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/shared/ui/select', () => ({
Select: ({ children, value, onValueChange, disabled }: { children: ReactNode; value: string; onValueChange: (value: string) => void; disabled?: boolean }) => {
const trigger = Children.toArray(children)[0] as ReactElement<{ ref?: Ref<HTMLSelectElement> }>
return <select ref={trigger.props.ref} aria-label="Role" value={value} disabled={disabled} onChange={(event) => onValueChange(event.target.value)}>{children}</select>
},
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => children,
SelectItem: ({ children, value }: { children: ReactNode; value: string }) => <option value={value}>{children}</option>,
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useMyNamespaces: () => ({ data: [{ slug: 'team', currentUserRole: state.role }] }),
useNamespaceDetail: () => ({ data: { slug: 'team', type: 'TEAM', status: state.status }, isLoading: false }),
useNamespaceMembers: () => ({ data: { items: [
{ id: 1, userId: 'owner', displayName: 'Owner', role: 'OWNER', createdAt: '2026-10-13T00:00:00Z' },
{ id: 2, userId: 'member', displayName: 'Member', role: 'MEMBER', createdAt: '2026-10-13T00:00:00Z' },
], total: 2 }, isLoading: false }),
useUpdateNamespaceMemberRole: () => ({ mutateAsync: state.update }),
useRemoveNamespaceMember: () => ({ mutateAsync: state.remove }),
}))
import { NamespaceMembersPage } from './namespace-members'
afterEach(cleanup)
beforeEach(() => { state.role = 'OWNER'; state.status = 'ACTIVE'; state.update.mockReset().mockResolvedValue({}); state.remove.mockReset() })
describe('NamespaceMembersPage permissions and role actions', () => {
it('places back navigation before the namespace header', () => {
const { container } = render(<NamespaceMembersPage />)
expect(container.firstElementChild?.firstElementChild?.tagName).toBe('A')
})
it.each(['MEMBER', 'FROZEN', 'ARCHIVED'])('hides management buttons for %s', (mode) => {
if (mode === 'MEMBER') state.role = mode
else state.status = mode
render(<NamespaceMembersPage />)
expect(screen.queryByRole('button', { name: 'members.addMember' })).toBeNull()
expect(screen.queryByRole('button', { name: 'members.remove' })).toBeNull()
expect(screen.queryByRole('columnheader', { name: 'members.colActions' })).toBeNull()
})
it('never shows a removal button for the owner and places icon actions in the action column', async () => {
render(<NamespaceMembersPage />)
const ownerRow = screen.getByText('Owner').closest('tr')!
expect(within(ownerRow).queryByRole('button')).toBeNull()
const row = screen.getByText('Member').closest('tr')!
fireEvent.change(within(row).getByRole('combobox'), { target: { value: 'ADMIN' } })
const save = within(row).getByRole('button', { name: 'members.saveRole' })
expect(save.closest('td')).toBe(row.lastElementChild)
expect(save.getAttribute('title')).toBe('members.saveRole')
expect(save.querySelector('svg')).toBeTruthy()
save.focus()
fireEvent.click(save)
await waitFor(() => expect(state.update).toHaveBeenCalledWith({ slug: 'team', userId: 'member', role: 'ADMIN' }))
await waitFor(() => expect(document.activeElement).toBe(within(row).getByRole('combobox')))
})
it('preserves the selected role and save action when an async update fails', async () => {
state.update.mockRejectedValueOnce(new Error('unavailable'))
render(<NamespaceMembersPage />)
const role = screen.getByRole('combobox')
fireEvent.change(role, { target: { value: 'ADMIN' } })
fireEvent.click(screen.getByRole('button', { name: 'members.saveRole' }))
await waitFor(() => expect((role as HTMLSelectElement).disabled).toBe(false))
expect((role as HTMLSelectElement).value).toBe('ADMIN')
expect(screen.getByRole('button', { name: 'members.saveRole' })).toBeTruthy()
expect(state.update).toHaveBeenCalledTimes(1)
})
it('cancel clears the role draft without sending an update', () => {
render(<NamespaceMembersPage />)
const role = screen.getByRole('combobox')
fireEvent.change(role, { target: { value: 'ADMIN' } })
const cancel = screen.getByRole('button', { name: 'members.cancelRole' })
cancel.focus()
fireEvent.click(cancel)
expect(document.activeElement).toBe(role)
expect((role as HTMLSelectElement).value).toBe('MEMBER')
expect(state.update).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'members.saveRole' })).toBeNull()
})
})

View file

@ -1,11 +1,12 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Check, Loader2, Trash2, X } from 'lucide-react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog'
import { InvitationControls } from '@/features/namespace/invitation-controls'
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { TransferOwnershipDialog } from '@/features/namespace/transfer-ownership-dialog'
@ -49,6 +50,16 @@ export function NamespaceMembersPage() {
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null)
const [savingRoleUserId, setSavingRoleUserId] = useState<string | null>(null)
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
const roleSelectRefs = useRef<Record<string, HTMLButtonElement | null>>({})
const roleToFocus = useRef<string | null>(null)
useEffect(() => {
if (savingRoleUserId === null && roleToFocus.current) {
roleSelectRefs.current[roleToFocus.current]?.focus()
roleToFocus.current = null
}
}, [savingRoleUserId])
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: membersPage, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug, page, MEMBER_PAGE_SIZE)
@ -98,6 +109,7 @@ export function NamespaceMembersPage() {
t('members.updateRoleSuccessTitle'),
t('members.updateRoleSuccessDescription', { userId, role: nextRole }),
)
roleToFocus.current = userId
setDraftRoles((current) => {
const next = { ...current }
delete next[userId]
@ -153,6 +165,7 @@ export function NamespaceMembersPage() {
return (
<div className="space-y-8 animate-fade-up">
<NamespaceManagementBack />
<NamespaceHeader namespace={namespace} canEdit={canEditNamespace} />
<NamespaceManagementNav slug={slug} active="members" />
@ -163,7 +176,7 @@ export function NamespaceMembersPage() {
</Card>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
{canManageMembers ? <div className="flex flex-wrap items-center justify-end gap-2">
{canManageMembers ? (
<Dialog>
<DialogTrigger asChild><Button variant="outline">{t('namespaceManagement.invite')}</Button></DialogTrigger>
@ -188,10 +201,8 @@ export function NamespaceMembersPage() {
<Button>{t('members.addMember')}</Button>
</AddNamespaceMemberDialog>
</>
) : (
<Button disabled>{t('members.addMember')}</Button>
)}
</div>
) : null}
</div> : null}
{membersError ? (
<Card className="p-6 text-center text-red-600">
@ -213,7 +224,7 @@ export function NamespaceMembersPage() {
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colEmail')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colRole')}</th>
<th className="text-left p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colJoinedAt')}</th>
<th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th>
{canManageMembers ? <th className="text-right p-4 font-medium font-heading text-sm text-muted-foreground">{t('members.colActions')}</th> : null}
</tr>
</thead>
<tbody>
@ -237,6 +248,7 @@ export function NamespaceMembersPage() {
<div className="flex items-center gap-2">
<Select
value={roleValue}
disabled={isSavingRole || isRemoving}
onValueChange={(value) => {
setDraftRoles((current) => ({
...current,
@ -244,7 +256,7 @@ export function NamespaceMembersPage() {
}))
}}
>
<SelectTrigger className="w-36">
<SelectTrigger ref={(element) => { roleSelectRefs.current[member.userId] = element }} className="w-36" aria-label={t('members.colRole')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -252,18 +264,9 @@ export function NamespaceMembersPage() {
<SelectItem value="ADMIN">{t('members.roleAdmin')}</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={roleValue === member.role || isSavingRole}
onClick={() => handleRoleSave(member.userId, member.role)}
>
{isSavingRole ? t('members.savingRole') : t('members.saveRole')}
</Button>
</div>
) : (
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-accent/10 text-accent border border-accent/20">
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-secondary text-foreground border border-border/60">
{member.role === 'OWNER'
? t('members.roleOwner')
: member.role === 'ADMIN'
@ -272,20 +275,43 @@ export function NamespaceMembersPage() {
</span>
)}
</td>
<td className="p-4 text-sm text-muted-foreground">
<td className="p-4 text-sm whitespace-nowrap text-muted-foreground">
{formatLocalDateTime(member.createdAt, i18n.language, { dateStyle: 'medium' })}
</td>
<td className="p-4 text-right">
<Button
type="button"
variant="destructive"
size="sm"
disabled={!canManageMembers || isOwner || isRemoving}
onClick={() => setPendingRemoval({ userId: member.userId })}
>
{t('members.remove')}
</Button>
</td>
{canManageMembers ? (
<td className="p-4 text-right">
{!isOwner ? (
<div className="flex items-center justify-end gap-1">
{roleValue !== member.role ? (
<>
<Button type="button" variant="ghost" size="icon"
aria-label={t('members.saveRole')} title={t('members.saveRole')}
disabled={isSavingRole || isRemoving}
onClick={() => handleRoleSave(member.userId, member.role)}>
{isSavingRole ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Check className="h-4 w-4" aria-hidden="true" />}
</Button>
<Button type="button" variant="ghost" size="icon"
aria-label={t('members.cancelRole')} title={t('members.cancelRole')}
disabled={isSavingRole || isRemoving}
onClick={() => {
setDraftRoles((current) => { const next = { ...current }; delete next[member.userId]; return next })
roleSelectRefs.current[member.userId]?.focus()
}}>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</>
) : null}
<Button type="button" variant="ghost" size="icon"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label={t('members.remove')} title={t('members.remove')}
disabled={isRemoving || isSavingRole}
onClick={() => setPendingRemoval({ userId: member.userId })}>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
</td>
) : null}
</tr>
)
})}

View file

@ -67,7 +67,7 @@ vi.mock('@/features/namespace/namespace-header', () => ({
NamespaceHeader: () => null,
}))
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) }))
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => createElement('a', { href: '/dashboard/namespaces' }, 'back'), NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) }))
import { NamespaceReviewsPage } from './namespace-reviews'
@ -126,6 +126,14 @@ describe('NamespaceReviewsPage', () => {
expect(typeof NamespaceReviewsPage).toBe('function')
})
it('uses a status filter instead of nested review tabs', () => {
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
expect(html).toContain('nsReviews.statusLabel')
expect(html).toContain('nsReviews.tabPending')
expect(html).toContain('nsReviews.sortLabel')
expect(html).not.toContain('role="tab"')
})
it('renders pagination for namespace review list when totalPages > 1', () => {
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))

View file

@ -5,11 +5,9 @@ import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { Card } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
import { useReviewList } from '@/features/review/use-review-list'
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { Pagination } from '@/shared/components/pagination'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
@ -109,19 +107,25 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
}
return (
<Tabs value={activeStatus} onValueChange={(value) => setActiveStatus(value as ReviewStatus)}>
<section aria-label={t('nsReviews.title')}>
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<TabsList>
<TabsTrigger value="PENDING">{t('nsReviews.tabPending')}</TabsTrigger>
<TabsTrigger value="APPROVED">{t('nsReviews.tabApproved')}</TabsTrigger>
<TabsTrigger value="REJECTED">{t('nsReviews.tabRejected')}</TabsTrigger>
</TabsList>
<div className="w-full max-w-48">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<label htmlFor="namespace-review-status" className="mb-2 block text-sm font-medium text-muted-foreground">{t('nsReviews.statusLabel')}</label>
<Select value={activeStatus} onValueChange={(value) => setActiveStatus(value as ReviewStatus)}>
<SelectTrigger id="namespace-review-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">{t('nsReviews.tabPending')}</SelectItem>
<SelectItem value="APPROVED">{t('nsReviews.tabApproved')}</SelectItem>
<SelectItem value="REJECTED">{t('nsReviews.tabRejected')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full max-w-48">
<label htmlFor="namespace-review-sort" className="mb-2 block text-sm font-medium text-muted-foreground">
{t('nsReviews.sortLabel')}
</p>
</label>
<Select value={sortDirection} onValueChange={handleSortChange}>
<SelectTrigger>
<SelectTrigger id="namespace-review-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -131,10 +135,8 @@ function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug:
</Select>
</div>
</div>
<TabsContent value="PENDING" className="mt-0">{renderItems(pending, 'PENDING')}</TabsContent>
<TabsContent value="APPROVED" className="mt-0">{renderItems(approved, 'APPROVED')}</TabsContent>
<TabsContent value="REJECTED" className="mt-0">{renderItems(rejected, 'REJECTED')}</TabsContent>
</Tabs>
{renderItems(activeStatus === 'PENDING' ? pending : activeStatus === 'APPROVED' ? approved : rejected, activeStatus)}
</section>
)
}
@ -152,10 +154,7 @@ export function NamespaceReviewsPage() {
return (
<div className="space-y-8 animate-fade-up">
<DashboardPageHeader
title={t('nsReviews.title')}
subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
/>
<NamespaceManagementBack />
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
{readOnlyMessage ? (
<Card className="border-border/50 bg-secondary/40 p-4 text-sm text-muted-foreground">

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
import { NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
import { executeNamespaceAction, resolveNamespaceActionCopy, type PendingNamespaceAction } from '@/features/namespace/namespace-actions'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { EmptyState } from '@/shared/components/empty-state'
@ -33,6 +33,7 @@ export function NamespaceSettingsPage() {
const canEdit = namespace.type === 'TEAM' && namespace.status === 'ACTIVE' && (managed?.currentUserRole === 'OWNER' || managed?.currentUserRole === 'ADMIN')
return (
<div className="space-y-6">
<NamespaceManagementBack />
<NamespaceHeader namespace={namespace} canEdit={canEdit} />
<NamespaceManagementNav slug={slug} active="settings" />
<Card className="space-y-4 p-5">