From a56486812eba096229f0ddc7d1ee91fef57a5ab9 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:06:27 +0800 Subject: [PATCH] fix(web): expand invitation dialog and clarify its status Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../add-namespace-invite-links/design.md | 2 + .../specs/namespace-invitations/spec.md | 6 ++ .../namespace/invitation-controls.test.tsx | 36 ++++++++- .../namespace/invitation-controls.tsx | 78 ++++++++++++++----- web/src/i18n/locales/en.json | 15 +++- web/src/i18n/locales/ru.json | 15 +++- web/src/i18n/locales/zh.json | 15 +++- web/src/pages/dashboard/namespace-members.tsx | 4 +- 8 files changed, 139 insertions(+), 32 deletions(-) diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index 27bf8ac2..29c663a8 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -30,3 +30,5 @@ 返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。 邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。 + +邀请弹窗使用640px桌面宽度(手机为视口减32px),按邀请状态与链接、加入规则、管理操作分区。只在成功验证原邀请时展示实际到期时间;未知或失败不猜测到期时间,提供状态重试。复制为主要操作,替换和作废保留确认。 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 630d0c33..98e1cc5a 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 @@ -128,3 +128,9 @@ API SHALL 使用 Issue #911 的路径与响应合同。 - **AND** 无保存链接时说明限制,不自动替换邀请 - **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示 - **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分 + +#### Scenario: 邀请弹窗提供完整状态及说明 +- **WHEN** 有权管理员打开邀请弹窗 +- **THEN** 显示目标空间及邀请有效/无有效邀请/加载/读取失败状态;可恢复原链接时显示实际到期时间及复制入口 +- **AND** 说明24小时内单次使用、MEMBER权限、先登录及现有准入规则;替换和作废操作独立于复制,并继续要求确认 +- **AND** 读取失败可重试;桌面弹窗适当增宽,手机仍可完整操作 diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index 3cfecb6d..762ad856 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -33,7 +33,7 @@ describe('InvitationControls', () => { api.invitationStatus.mockResolvedValue({ active: true }) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render() - const button = await screen.findByRole('button', { name: action === 'replace' ? 'invitation.create' : 'invitation.revoke' }) + const button = await screen.findByRole('button', { name: action === 'replace' ? 'invitation.replaceAction' : 'invitation.revoke' }) await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) fireEvent.click(button) return screen.findByRole('dialog') @@ -60,7 +60,7 @@ describe('InvitationControls', () => { api.createInvitation.mockImplementation(() => new Promise(() => {})) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render() - const createButton = screen.getByRole('button', { name: 'invitation.create' }) + const createButton = await screen.findByRole('button', { name: 'invitation.replaceAction' }) await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) expect(screen.getByRole('button', { name: 'invitation.revoke' })).toBeTruthy() @@ -75,7 +75,7 @@ describe('InvitationControls', () => { api.invitationStatus.mockResolvedValueOnce({ active: true }).mockRejectedValue(new Error('unavailable')) const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render() - const createButton = screen.getByRole('button', { name: 'invitation.create' }) + const createButton = await screen.findByRole('button', { name: 'invitation.replaceAction' }) await waitFor(() => expect((createButton as HTMLButtonElement).disabled).toBe(false)) fireEvent.click(createButton) @@ -204,7 +204,7 @@ describe('InvitationControls', () => { api.createInvitation.mockResolvedValue({ token: nextToken, replaced: true }) const view = mount() await screen.findByRole('textbox') - fireEvent.click(screen.getByRole('button', { name: 'invitation.create' })) + fireEvent.click(screen.getByRole('button', { name: 'invitation.replaceAction' })) const dialog = await screen.findByRole('dialog') expect(api.createInvitation).not.toHaveBeenCalled() expect(window.sessionStorage.getItem(storageKey)).toBe(token) @@ -218,4 +218,32 @@ describe('InvitationControls', () => { expect(api.createInvitation).toHaveBeenCalledTimes(1) }) + it('shows the verified expiration with the link and distinguishes replacement from creation', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockResolvedValue(validPreview) + mount() + await screen.findByRole('textbox') + expect(screen.getByText(/invitation.expiresAtLabel/).textContent).toContain('2999') + expect(screen.getByRole('status').textContent).toBe('invitation.statusActive') + expect(screen.queryByRole('button', { name: 'invitation.create' })).toBeNull() + expect(screen.getByRole('button', { name: 'invitation.replaceAction' })).toBeTruthy() + expect(screen.getByText(/invitation.singleUse/)).toBeTruthy() + expect(screen.getByText('invitation.accessPolicy')).toBeTruthy() + }) + + it('retries failed validation without creating or replacing an invitation', async () => { + window.sessionStorage.setItem(storageKey, token) + api.invitationStatus.mockResolvedValue({ active: true }) + api.previewInvitation.mockRejectedValueOnce(new ApiError('network', 0)).mockResolvedValue(validPreview) + mount() + await screen.findByRole('alert') + expect(screen.getByRole('status').textContent).toBe('invitation.statusError') + fireEvent.click(screen.getByRole('button', { name: 'invitation.retry' })) + expect((await screen.findByRole('textbox') as HTMLInputElement).value).toContain(token) + expect(screen.queryByRole('alert')).toBeNull() + expect(api.createInvitation).not.toHaveBeenCalled() + expect(api.revokeInvitation).not.toHaveBeenCalled() + }) + }) diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index fe37ae60..c1ceece4 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -1,6 +1,7 @@ import { useId, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { Copy, Link2, Loader2, RefreshCw, ShieldCheck, Trash2, Users } from 'lucide-react' import { getAppBaseUrl, namespaceApi } from '@/api/client' import { useAuth } from '@/features/auth/use-auth' import { ApiError } from '@/shared/lib/api-error' @@ -13,7 +14,7 @@ import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' export function InvitationControls({ slug, compact = false }: { slug: string; compact?: boolean }) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const linkId = useId() const queryClient = useQueryClient() const { user } = useAuth() @@ -33,28 +34,33 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co const current = await namespaceApi.invitationStatus(slug) if (!current.active) { clearStoredToken() - return { active: false, token: null } + return { active: false, token: null, expiresAt: 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 } + if (!token) return { active: true, token: null, expiresAt: null } + let expiresAt: string | null = null try { const preview = await namespaceApi.previewInvitation(token) + expiresAt = preview.expiresAt if (preview.namespaceSlug !== slug || Date.parse(preview.expiresAt) <= Date.now()) { clearStoredToken() - return { active: true, token: null } + return { active: true, token: null, expiresAt: null } } } catch (error) { if (!(error instanceof ApiError) || error.status !== 404) throw error clearStoredToken() - return { active: true, token: null } + return { active: true, token: null, expiresAt: null } } - return { active: true, token } + return { active: true, token, expiresAt } }, }) const link = !status.isFetching && !status.isError && status.data?.token ? invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), status.data.token) : null + const expiresAt = !status.isFetching && !status.isError && status.data?.expiresAt + ? new Intl.DateTimeFormat(i18n?.language ?? 'zh', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.data.expiresAt)) + : null const copyLink = async (url: string, replaced = false) => { try { await navigator.clipboard.writeText(url) @@ -87,23 +93,55 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co else create.mutate(false) } return ( -
event.stopPropagation() : undefined}> -
- - {status.data?.active ? : null} -
- {link ? ( -
- -
- event.currentTarget.select()} /> - +
event.stopPropagation() : undefined}> + {!compact ? ( +
+
+ + + {status.isFetching ? +
+ {link ? ( +
+ +
+ event.currentTarget.select()} /> + +
+ {expiresAt ?

{t('invitation.expiresAtLabel')}: {expiresAt}

: null} +

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

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

{t('invitation.activeUnavailable')}

: null} + {!status.isError && !status.data?.active ? ( +
+ ) : null} +
+ ) : } + {!compact ? ( +
+

{t('invitation.detailsTitle')}

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

{t('invitation.error')}

+ {status.error ? : null} +
+ ) : null} + {!compact && !status.isError && status.data?.active ? ( +
+ {t('invitation.managementTitle')} +
+ +
-

{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')} description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f89f70cb..2f91cc42 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1406,7 +1406,6 @@ "invalid": "This invitation link is invalid or expired. Ask a space admin for a new link.", "loading": "Checking invitation…", "networkError": "Connection failed. Try again.", - "retry": "Retry", "namespace": "Space: {{name}}", "account": "Current account: {{name}}", "permissions": "Confirm to join as MEMBER. You can access space resources and publish skills.", @@ -1416,7 +1415,19 @@ "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." + "storageUnavailable": "Your browser could not save the link. Copy it now; it cannot be restored after closing this dialog or refreshing.", + "statusLoading": "Checking invitation status", + "statusActive": "Invitation active", + "statusInactive": "No active invitation", + "detailsTitle": "How joining works", + "singleUse": "One new member can use this invitation within 24 hours.", + "memberAccess": "The recipient joins as a member and can access skills shared with this space.", + "accessPolicy": "The recipient must sign in and meet existing admission rules. Opening the link does not join automatically.", + "replaceAction": "Replace invitation link", + "managementTitle": "Manage invitation", + "retry": "Refresh status", + "expiresAtLabel": "Expires at", + "statusError": "Unable to check invitation status" }, "members": { "title": "Member Management", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 39371feb..528981dd 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1471,7 +1471,6 @@ "invalid": "Ссылка-приглашение недействительна или истекла. Запросите новую ссылку у администратора.", "loading": "Проверка приглашения…", "networkError": "Ошибка соединения. Попробуйте ещё раз.", - "retry": "Повторить", "namespace": "Пространство: {{name}}", "account": "Текущая учётная запись: {{name}}", "permissions": "Подтвердите вступление с ролью MEMBER. Вы сможете просматривать ресурсы и публиковать навыки.", @@ -1481,7 +1480,19 @@ "activeUnavailable": "Приглашение активно, но ссылка не сохранена в этой вкладке. Скопируйте её из вкладки, в которой создали приглашение, или подтвердите замену ссылки.", "localLinkHint": "Ссылка сохранена только для текущего аккаунта в этой вкладке; один новый участник может использовать её в течение 24 часов.", "copy": "Копировать ссылку", - "storageUnavailable": "Браузер не смог сохранить ссылку. Скопируйте её сейчас; после закрытия окна или обновления восстановить её не получится." + "storageUnavailable": "Браузер не смог сохранить ссылку. Скопируйте её сейчас; после закрытия окна или обновления восстановить её не получится.", + "statusLoading": "Проверка приглашения", + "statusActive": "Приглашение активно", + "statusInactive": "Нет активного приглашения", + "detailsTitle": "Правила вступления", + "singleUse": "Приглашение действует 24 часа и доступно одному новому участнику.", + "memberAccess": "Получатель становится участником и получает доступ к навыкам пространства, доступным участникам.", + "accessPolicy": "Получатель должен войти и пройти действующие правила допуска. Открытие ссылки не добавляет его автоматически.", + "replaceAction": "Заменить ссылку приглашения", + "managementTitle": "Управление приглашением", + "retry": "Обновить статус", + "expiresAtLabel": "Истекает", + "statusError": "Не удалось проверить статус приглашения" }, "members": { "title": "Управление участниками", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 36f2369c..b18d75a5 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1406,7 +1406,6 @@ "invalid": "邀请链接无效或已失效,请联系空间管理员获取新链接。", "loading": "正在核对邀请…", "networkError": "连接失败,请重试。", - "retry": "重试", "namespace": "目标空间:{{name}}", "account": "当前账号:{{name}}", "permissions": "确认后,你将以 MEMBER 身份加入,可访问空间资源并发布技能。", @@ -1416,7 +1415,19 @@ "activeUnavailable": "当前邀请仍有效,但此标签页没有保存完整链接。可在创建链接的标签页复制,或确认生成新链接使旧链接失效。", "localLinkHint": "链接仅保存在当前账号的这个浏览器标签页;24 小时内可供一名新成员使用。", "copy": "复制链接", - "storageUnavailable": "浏览器未能保存链接,请现在复制;关闭此窗口或刷新后将无法恢复。" + "storageUnavailable": "浏览器未能保存链接,请现在复制;关闭此窗口或刷新后将无法恢复。", + "statusLoading": "正在获取邀请状态", + "statusActive": "邀请有效", + "statusInactive": "暂无有效邀请", + "detailsTitle": "加入规则", + "singleUse": "单次邀请:仅限一名新成员使用,有效期 24 小时。", + "memberAccess": "加入后获得成员权限,可访问空间内对成员开放的技能。", + "accessPolicy": "对方须先登录并通过平台现有准入,打开链接不会自动加入。", + "replaceAction": "替换邀请链接", + "managementTitle": "管理邀请", + "retry": "重新获取状态", + "expiresAtLabel": "到期时间", + "statusError": "邀请状态获取失败" }, "members": { "title": "成员管理", diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 14f78e71..dfab9b88 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -180,9 +180,9 @@ export function NamespaceMembersPage() { {canManageMembers ? ( - + {t('namespaceManagement.invite')} - {t('invitation.linkHint')} + {namespace.displayName} @{slug}