mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-16 04:28:39 +00:00
fix(web): expand invitation dialog and clarify its status
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
5916771237
commit
a56486812e
8 changed files with 139 additions and 32 deletions
|
|
@ -30,3 +30,5 @@
|
|||
返回我的空间放在标题上方。普通成员不显示变更操作;角色编辑仍在角色列,保存/取消/移除使用操作列图标,提供可访问名称和悬停说明,保存成功或取消后恢复角色选择框焦点。审核状态用下拉筛选区分空间主导航。
|
||||
|
||||
邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。
|
||||
|
||||
邀请弹窗使用640px桌面宽度(手机为视口减32px),按邀请状态与链接、加入规则、管理操作分区。只在成功验证原邀请时展示实际到期时间;未知或失败不猜测到期时间,提供状态重试。复制为主要操作,替换和作废保留确认。
|
||||
|
|
|
|||
|
|
@ -128,3 +128,9 @@ API SHALL 使用 Issue #911 的路径与响应合同。
|
|||
- **AND** 无保存链接时说明限制,不自动替换邀请
|
||||
- **AND** 返回我的空间位于空间标题上方;无管理权限不显示成员变更按钮;有权限的角色保存、取消和移除放在操作列,图标有可访问名称及悬停提示
|
||||
- **AND** 审核状态采用带标签的下拉筛选,与空间主导航区分
|
||||
|
||||
#### Scenario: 邀请弹窗提供完整状态及说明
|
||||
- **WHEN** 有权管理员打开邀请弹窗
|
||||
- **THEN** 显示目标空间及邀请有效/无有效邀请/加载/读取失败状态;可恢复原链接时显示实际到期时间及复制入口
|
||||
- **AND** 说明24小时内单次使用、MEMBER权限、先登录及现有准入规则;替换和作废操作独立于复制,并继续要求确认
|
||||
- **AND** 读取失败可重试;桌面弹窗适当增宽,手机仍可完整操作
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('InvitationControls', () => {
|
|||
api.invitationStatus.mockResolvedValue({ active: true })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
|
||||
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(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
|
||||
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(<QueryClientProvider client={client}><InvitationControls slug="team" /></QueryClientProvider>)
|
||||
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()
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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={!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>
|
||||
<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 id={compact ? undefined : 'invite'} className="space-y-5 scroll-mt-24" onClick={compact ? (event) => event.stopPropagation() : undefined}>
|
||||
{!compact ? (
|
||||
<div className="rounded-xl border bg-secondary/30 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-medium"><Link2 className="h-4 w-4" aria-hidden="true" />{t('invitation.link')}</span>
|
||||
<span role="status" className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-xs font-medium">
|
||||
{status.isFetching ? <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" /> : null}
|
||||
{t(status.isError ? 'invitation.statusError' : status.isFetching ? 'invitation.statusLoading' : status.data?.active ? 'invitation.statusActive' : 'invitation.statusInactive')}
|
||||
</span>
|
||||
</div>
|
||||
{link ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className="sr-only" htmlFor={linkId}>{t('invitation.link')}</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input id={linkId} className="min-w-0 flex-1 bg-background font-mono text-xs" readOnly value={link} onFocus={(event) => event.currentTarget.select()} />
|
||||
<Button className="shrink-0" onClick={() => void copyLink(link)}><Copy className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.copy')}</Button>
|
||||
</div>
|
||||
{expiresAt ? <p className="text-xs text-muted-foreground">{t('invitation.expiresAtLabel')}: {expiresAt}</p> : null}
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{t(storageUnavailable ? 'invitation.storageUnavailable' : 'invitation.localLinkHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!link && !status.isFetching && !status.isError && status.data?.active ? <p className="mt-3 text-sm leading-relaxed text-muted-foreground">{t('invitation.activeUnavailable')}</p> : null}
|
||||
{!status.isError && !status.data?.active ? (
|
||||
<div className="mt-4"><Button disabled={!user || status.isPending || status.isFetching || create.isPending} onClick={handleCreate}><Link2 className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.create')}</Button></div>
|
||||
) : null}
|
||||
</div>
|
||||
) : <Button variant="outline" size="sm" disabled={!user || status.isFetching || create.isPending} onClick={handleCreate}>{t('invitation.create')}</Button>}
|
||||
{!compact ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="font-medium">{t('invitation.detailsTitle')}</p>
|
||||
<div className="flex gap-3"><Users className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" /><p className="leading-relaxed text-muted-foreground">{t('invitation.singleUse')} {t('invitation.memberAccess')}</p></div>
|
||||
<div className="flex gap-3"><ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" /><p className="leading-relaxed text-muted-foreground">{t('invitation.accessPolicy')}</p></div>
|
||||
</div>
|
||||
) : null}
|
||||
{(create.error || revoke.error || status.error) ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
||||
<p role="alert" className="text-sm text-destructive">{t('invitation.error')}</p>
|
||||
{status.error ? <Button variant="outline" size="sm" disabled={status.isFetching} onClick={() => void status.refetch()}><RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.retry')}</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!compact && !status.isError && status.data?.active ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
|
||||
<span className="text-xs text-muted-foreground">{t('invitation.managementTitle')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!user || status.isFetching || create.isPending || revoke.isPending} onClick={handleCreate}><RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.replaceAction')}</Button>
|
||||
<Button variant="ghost" className="text-destructive hover:bg-destructive/10 hover:text-destructive" disabled={status.isFetching || revoke.isPending || create.isPending} onClick={() => setConfirm('revoke')}><Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />{t('invitation.revoke')}</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')}
|
||||
description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Управление участниками",
|
||||
|
|
|
|||
|
|
@ -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": "成员管理",
|
||||
|
|
|
|||
|
|
@ -180,9 +180,9 @@ export function NamespaceMembersPage() {
|
|||
{canManageMembers ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild><Button variant="outline">{t('namespaceManagement.invite')}</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-[min(calc(100vw-2rem),40rem)] gap-5 p-5 sm:p-6">
|
||||
<DialogTitle>{t('namespaceManagement.invite')}</DialogTitle>
|
||||
<DialogDescription>{t('invitation.linkHint')}</DialogDescription>
|
||||
<DialogDescription className="break-words">{namespace.displayName} <span className="text-muted-foreground">@{slug}</span></DialogDescription>
|
||||
<InvitationControls slug={slug} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue