Workspaces 4 members invites (#8245)

## Summary

  Add team workspace member management and invite system.

## Changes

- Add members panel with role management (owner/admin/member) and member
removal
- Add invite system with email invites, pending invite display, and
revoke functionality
   - Add invite URL loading for accepting invites
  - Add subscription panel updates for member management
  - Add i18n translations for member and invite features

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8245-Workspaces-4-members-invites-2f06d73d36508176b2caf852a1505c4a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Simula_r
2026-01-24 15:52:40 -08:00
committed by GitHub
parent aa6f9b7009
commit 4771565486
31 changed files with 1704 additions and 121 deletions

View File

@@ -79,8 +79,7 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

View File

@@ -69,7 +69,7 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})

View File

@@ -0,0 +1,182 @@
<template>
<div
class="flex w-full max-w-[512px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{
step === 'email'
? $t('workspacePanel.inviteMemberDialog.title')
: $t('workspacePanel.inviteMemberDialog.linkStep.title')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body: Email Step -->
<template v-if="step === 'email'">
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.message') }}
</p>
<input
v-model="email"
type="email"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="$t('workspacePanel.inviteMemberDialog.placeholder')"
/>
</div>
<!-- Footer: Email Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidEmail"
@click="onCreateLink"
>
{{ $t('workspacePanel.inviteMemberDialog.createLink') }}
</Button>
</div>
</template>
<!-- Body: Link Step -->
<template v-else>
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.message') }}
</p>
<p class="m-0 text-sm font-medium text-base-foreground">
{{ email }}
</p>
<div class="relative">
<input
:value="generatedLink"
readonly
class="w-full cursor-pointer rounded-lg border border-border-default bg-transparent px-3 py-2 pr-10 text-sm text-base-foreground focus:outline-none"
@click="onSelectLink"
/>
<div
class="absolute right-4 top-2 cursor-pointer"
@click="onCopyLink"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clip-path="url(#clip0_2127_14348)">
<path
d="M2.66634 10.6666C1.93301 10.6666 1.33301 10.0666 1.33301 9.33325V2.66659C1.33301 1.93325 1.93301 1.33325 2.66634 1.33325H9.33301C10.0663 1.33325 10.6663 1.93325 10.6663 2.66659M6.66634 5.33325H13.333C14.0694 5.33325 14.6663 5.93021 14.6663 6.66658V13.3333C14.6663 14.0696 14.0694 14.6666 13.333 14.6666H6.66634C5.92996 14.6666 5.33301 14.0696 5.33301 13.3333V6.66658C5.33301 5.93021 5.92996 5.33325 6.66634 5.33325Z"
stroke="white"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_2127_14348">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</div>
</div>
<!-- Footer: Link Step -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="primary" size="lg" @click="onCopyLink">
{{ $t('workspacePanel.inviteMemberDialog.linkStep.copyLink') }}
</Button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const toast = useToast()
const { t } = useI18n()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const email = ref('')
const step = ref<'email' | 'link'>('email')
const generatedLink = ref('')
const isValidEmail = computed(() => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email.value)
})
function onCancel() {
dialogStore.closeDialog({ key: 'invite-member' })
}
async function onCreateLink() {
if (!isValidEmail.value) return
loading.value = true
try {
generatedLink.value = await workspaceStore.createInviteLink(email.value)
step.value = 'link'
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
async function onCopyLink() {
try {
await navigator.clipboard.writeText(generatedLink.value)
toast.add({
severity: 'success',
summary: t('workspacePanel.inviteMemberDialog.linkCopied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
})
}
}
function onSelectLink(event: Event) {
const input = event.target as HTMLInputElement
input.select()
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.removeMemberDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.removeMemberDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRemove">
{{ $t('workspacePanel.removeMemberDialog.remove') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { memberId } = defineProps<{
memberId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'remove-member' })
}
async function onRemove() {
loading.value = true
try {
await workspaceStore.removeMember(memberId)
toast.add({
severity: 'success',
summary: t('workspacePanel.removeMemberDialog.success'),
life: 2000
})
dialogStore.closeDialog({ key: 'remove-member' })
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.revokeInviteDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.revokeInviteDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onRevoke">
{{ $t('workspacePanel.revokeInviteDialog.revoke') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { inviteId } = defineProps<{
inviteId: string
}>()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const toast = useToast()
const { t } = useI18n()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'revoke-invite' })
}
async function onRevoke() {
loading.value = true
try {
await workspaceStore.revokeInvite(inviteId)
dialogStore.closeDialog({ key: 'revoke-invite' })
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
}
}
</script>