diff --git a/docs/26-skill-avatars.md b/docs/26-skill-avatars.md index c06f9a02..674a7c8b 100644 --- a/docs/26-skill-avatars.md +++ b/docs/26-skill-avatars.md @@ -1,6 +1,6 @@ # 技能头像与审核 -技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。 +技能所有者可在新增/更新技能页面选择自定义 PNG/JPEG 头像,网页提供方形裁剪和缩放;不提供预设图标。更新默认保留头像,也可替换或移除。“我的技能”统一从“更新”入口维护,也可只改头像而不发布新版本;详情页仅在标题旁展示当前头像。服务器仍会独立校验:输入不超过 2 MB、边长不超过 1024 像素,并重新编码为 PNG,避免依赖客户端裁剪或保留原图元数据。头像候选存在私有对象存储;审核完成前,公众继续看到旧头像或技能名首字母。所有者在“我的技能”和更新页可查看已提交头像的缩略图及审核状态,待审头像不会替代公开头像;加载失败会提示重试。 ## 上传与提交 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index 20b848be..27bf8ac2 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -24,3 +24,9 @@ 新分享地址为 `/join#invite=`,兼容原 `/join#`。进入页仍在登录跳转前同步捕获并清片段,Token不进入HTTP路径、查询、returnTo或日志。新片段无效时清旧暂存Token,无片段登录回跳才恢复暂存值。 命名空间卡片不平铺成员、邀请、作废、冻结、归档按钮;管理详情按技能/成员/审核/设置集中导航。成员页弹窗管理邀请,治理操作进入设置并保留旧权限与危险动作确认。公共空间技能浏览及旧成员/审核链接保持可用。平台审核权不等于成员管理权;GLOBAL不可冻结归档,但合法技能审核仍可用。 + +### 2026-10-14 人工核验交互修正 + +返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。 + +邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index da305791..630d0c33 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -37,7 +37,7 @@ Token SHALL 避免出现在日志、持久化明文和登录 URL 中。 -Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。分享 URL 使用 `/join#invite=`,页面立即移入当前标签页 sessionStorage 并清除 fragment;登录回跳只包含应用内 `/join`,不带 Token。创建、预览、兑换、作废响应禁止缓存;日志和审计不得包含原文或哈希。四类操作仅允许浏览器 Session,变更请求遵守 CSRF。 +Token 使用安全随机数,数据库仅保存哈希;原文只在生成响应中出现一次。创建者页面可将原文保存在按账号和空间隔离的当前标签页 sessionStorage,供重开弹窗或刷新后恢复;展示前须重新确认服务端 active 状态并预览该 Token,失效或替换时清除。不得存入 localStorage 或数据库明文;其他标签页或设备无法恢复时明确说明,并保留确认替换入口。分享 URL 使用 `/join#invite=`,页面立即移入当前标签页 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** 审核状态采用带标签的下拉筛选,与空间主导航区分 diff --git a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md index db4b0d4b..e06670f3 100644 --- a/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md +++ b/openspec/changes/add-skill-avatars-and-moderation/specs/skill-avatars/spec.md @@ -202,3 +202,11 @@ PRIVATE 且仅所有者可访问的头像完成文件安全处理后可直接使 - **WHEN** 审核员曾看到空列表,其他登录会话随后提交新申请,审核员重新进入列表 - **THEN** 重新获取当前申请,不能仅以此前缓存的空列表显示“暂无审核记录” - **AND** 命名空间、状态、分页和排序仍各自隔离,不新增轮询或扩大权限 + +### Requirement: 提交者 SHALL 可查看已提交头像 + +技能所有者在我的技能及更新页 SHALL 可查看已提交头像的紧凑缩略图及当前审核状态。待审候选与当前公开头像须明确区分;通过前公开展示保持旧头像。候选图片只使用现有所有者受保护接口,不公开未审核内容;无法加载候选时显示明确提示,不冒充现用头像。 + +#### Scenario: 头像申请等待审核 +- **WHEN** 所有者提交新头像并返回我的技能或重开更新页 +- **THEN** 可看到所提交的新头像及审核状态,而公共列表和详情继续展示旧头像 diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index df80128e..3cfecb6d 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -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() + } + + 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) + }) + }) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index 043e59c5..fe37ae60 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -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(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 (
event.stopPropagation() : undefined}>
- + {status.data?.active ? : null}
{link ? (
- event.currentTarget.select()} /> -

{t('invitation.linkHint')}

+
+ event.currentTarget.select()} /> + +
+

{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}

) : null} + {!link && !status.isFetching && !status.isError && status.data?.active ?

{t('invitation.activeUnavailable')}

: null} {(create.error || revoke.error || status.error) ?

{t('invitation.error')}

: null} { if (!open) setConfirm(null) }} title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} diff --git a/web/src/features/namespace/namespace-management-nav.tsx b/web/src/features/namespace/namespace-management-nav.tsx index 022c613a..c542cfd3 100644 --- a/web/src/features/namespace/namespace-management-nav.tsx +++ b/web/src/features/namespace/namespace-management-nav.tsx @@ -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 ( + +