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

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white" class="flex size-8 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{ :style="{
background: gradient, background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)' textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'

View File

@@ -11,7 +11,7 @@
: '' : ''
]" ]"
v-bind="item.dialogComponentProps" v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt" :pt="getDialogPt(item)"
:aria-labelledby="item.key" :aria-labelledby="item.key"
> >
<template #header> <template #header>
@@ -41,12 +41,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { computed } from 'vue'
const { flags } = useFeatureFlags() const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed( const teamWorkspacesEnabled = computed(
@@ -54,6 +57,22 @@ const teamWorkspacesEnabled = computed(
) )
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
}
</script> </script>
<style> <style>
@@ -73,10 +92,13 @@ const dialogStore = useDialogStore()
.settings-dialog-workspace { .settings-dialog-workspace {
width: 100%; width: 100%;
max-width: 1440px; max-width: 1440px;
height: 100%;
} }
.settings-dialog-workspace .p-dialog-content { .settings-dialog-workspace .p-dialog-content {
width: 100%; width: 100%;
height: 100%;
overflow-y: auto;
} }
.manager-dialog { .manager-dialog {

View File

@@ -31,7 +31,12 @@
}}</label> }}</label>
</div> </div>
<Button variant="secondary" autofocus @click="onCancel"> <Button
v-if="type !== 'info'"
variant="secondary"
autofocus
@click="onCancel"
>
<i class="pi pi-undo" /> <i class="pi pi-undo" />
{{ $t('g.cancel') }} {{ $t('g.cancel') }}
</Button> </Button>
@@ -73,6 +78,10 @@
<i class="pi pi-eraser" /> <i class="pi pi-eraser" />
{{ $t('desktopMenu.reinstall') }} {{ $t('desktopMenu.reinstall') }}
</Button> </Button>
<!-- Info - just show an OK button -->
<Button v-else-if="type === 'info'" variant="primary" @click="onCancel">
{{ $t('g.ok') }}
</Button>
<!-- Invalid - just show a close button. --> <!-- Invalid - just show a close button. -->
<Button v-else variant="primary" @click="onCancel"> <Button v-else variant="primary" @click="onCancel">
<i class="pi pi-times" /> <i class="pi pi-times" />

View File

@@ -0,0 +1,511 @@
<template>
<div class="grow overflow-auto pt-6">
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke border-inter p-6"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
})
}}
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</div>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</div>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<div
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="userPhotoUrl"
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ userDisplayName }}
<span class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
</div>
</template>
<!-- Team Workspace: sorted list (owner first, current user second, then rest) -->
<template v-else>
<div
v-for="(member, index) in filteredMembers"
:key="member.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="
isCurrentUser(member) ? userPhotoUrl : undefined
"
:pt:icon:class="{
'text-xl!': !isCurrentUser(member) || !userPhotoUrl
}"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span
v-if="isCurrentUser(member)"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<span
v-if="uiConfig.showRoleBadge"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getRoleBadgeLabel(member.role) }}
</span>
</div>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only, can't remove yourself) -->
<div
v-if="permissions.canRemoveMembers"
class="flex items-center justify-end"
>
<Button
v-if="!isCurrentUser(member)"
v-tooltip="{
value: $t('g.moreOptions'),
showDelay: 300
}"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="showMemberMenu($event, member)"
>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
</template>
<!-- Pending Invites -->
<template v-else>
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.pendingGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<!-- Invite info -->
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="
$t('workspacePanel.members.actions.revokeInvite')
"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { copyInviteLink } = workspaceStore
const { permissions, uiConfig } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
function isCurrentUser(member: WorkspaceMember): boolean {
return member.email.toLowerCase() === userEmail.value?.toLowerCase()
}
// All members sorted: owners first, current user second, then rest by join date
const filteredMembers = computed(() => {
let result = [...members.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
// Owners always come first
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
// Current user comes second (after owner)
const aIsCurrentUser = isCurrentUser(a)
const bIsCurrentUser = isCurrentUser(b)
if (aIsCurrentUser && !bIsCurrentUser) return -1
if (!aIsCurrentUser && bIsCurrentUser) return 1
// Then sort by join date
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function getRoleBadgeLabel(role: 'owner' | 'member'): string {
return role === 'owner'
? t('workspaceSwitcher.roleOwner')
: t('workspaceSwitcher.roleMember')
}
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value === 'joinDate' ? 'inviteDate' : sortField.value
result.sort((a, b) => {
const aDate = a[field]
const bDate = b[field]
if (!aDate || !bDate) return 0
const aValue = aDate.getTime()
const bValue = bDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -9,17 +9,66 @@
{{ workspaceName }} {{ workspaceName }}
</h1> </h1>
</div> </div>
<Tabs :value="activeTab" @update:value="setActiveTab"> <Tabs unstyled :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center"> <div class="flex w-full items-center">
<TabList class="w-full"> <TabList unstyled class="flex w-full gap-2">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab> <Tab
value="plan"
:class="
cn(
buttonVariants({
variant: activeTab === 'plan' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'plan' && 'text-base-foreground no-underline'
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</Tab>
<Tab
value="members"
:class="
cn(
buttonVariants({
variant: activeTab === 'members' ? 'secondary' : 'textonly',
size: 'md'
}),
activeTab === 'members' && 'text-base-foreground no-underline',
'ml-2'
)
"
>
{{
$t('workspacePanel.tabs.membersCount', {
count: isInPersonalWorkspace ? 1 : members.length
})
}}
</Tab>
</TabList> </TabList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu"> <template v-if="permissions.canAccessWorkspaceMenu">
<Button <Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }" v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly" class="ml-2"
size="icon" variant="secondary"
size="lg"
:aria-label="$t('g.moreOptions')" :aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)" @click="menu?.toggle($event)"
> >
@@ -36,7 +85,7 @@
:class="[ :class="[
'flex items-center gap-2 px-3 py-2', 'flex items-center gap-2 px-3 py-2',
item.class, item.class,
item.disabled ? 'pointer-events-auto' : '' item.disabled ? 'pointer-events-auto' : 'cursor-pointer'
]" ]"
@click=" @click="
item.command?.({ item.command?.({
@@ -53,9 +102,12 @@
</template> </template>
</div> </div>
<TabPanels> <TabPanels unstyled>
<TabPanel value="plan"> <TabPanel value="plan">
<SubscriptionPanelContent /> <SubscriptionPanelContentWorkspace />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
@@ -74,8 +126,11 @@ import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue' import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue' import { buttonVariants } from '@/components/ui/button/button.variants'
import SubscriptionPanelContentWorkspace from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { cn } from '@/utils/tailwindUtil'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
@@ -88,12 +143,20 @@ const { t } = useI18n()
const { const {
showLeaveWorkspaceDialog, showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog, showDeleteWorkspaceDialog,
showInviteMemberDialog,
showEditWorkspaceDialog showEditWorkspaceDialog
} = useDialogService() } = useDialogService()
const workspaceStore = useTeamWorkspaceStore() const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore) const {
workspaceName,
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI() members,
isInviteLimitReached,
isWorkspaceSubscribed,
isInPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null) const menu = ref<InstanceType<typeof Menu> | null>(null)
@@ -123,6 +186,16 @@ const deleteTooltip = computed(() => {
return tooltipKey ? t(tooltipKey) : null return tooltipKey ? t(tooltipKey) : null
}) })
const inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
const menuItems = computed(() => { const menuItems = computed(() => {
const items = [] const items = []
@@ -159,5 +232,7 @@ const menuItems = computed(() => {
onMounted(() => { onMounted(() => {
setActiveTab(defaultTab) setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
}) })
</script> </script>

View File

@@ -79,8 +79,7 @@ const workspaceName = ref('')
const isValidName = computed(() => { const isValidName = computed(() => {
const name = workspaceName.value.trim() 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) 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 isValidName = computed(() => {
const name = newWorkspaceName.value.trim() 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) 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>

View File

@@ -160,6 +160,9 @@ import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil' import { forEachNode } from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue' import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const emit = defineEmits<{ const emit = defineEmits<{
ready: [] ready: []
@@ -394,6 +397,9 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false) const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence() const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
// Set up invite loader during setup phase so useRoute/useRouter work correctly
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
useCanvasDrop(canvasRef) useCanvasDrop(canvasRef)
useLitegraphSettings() useLitegraphSettings()
useNodeBadge() useNodeBadge()
@@ -459,6 +465,22 @@ onMounted(async () => {
// Load template from URL if present // Load template from URL if present
await workflowPersistence.loadTemplateFromUrlIfPresent() await workflowPersistence.loadTemplateFromUrlIfPresent()
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
// Uses watch because feature flags load asynchronously - flag may be false initially
// then become true once remoteConfig or websocket features are loaded
if (inviteUrlLoader) {
const stopWatching = watch(
() => flags.teamWorkspacesEnabled,
async (enabled) => {
if (enabled) {
stopWatching()
await inviteUrlLoader.loadInviteFromUrl()
}
},
{ immediate: true }
)
}
// Initialize release store to fetch releases from comfy-api (fire-and-forget) // Initialize release store to fetch releases from comfy-api (fire-and-forget)
const { useReleaseStore } = const { useReleaseStore } =
await import('@/platform/updates/common/releaseStore') await import('@/platform/updates/common/releaseStore')

View File

@@ -27,7 +27,7 @@
:class="compact && 'size-full'" :class="compact && 'size-full'"
/> />
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" /> <i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
</div> </div>
</Button> </Button>

View File

@@ -36,15 +36,6 @@
<span class="truncate text-sm text-base-foreground">{{ <span class="truncate text-sm text-base-foreground">{{
workspaceName workspaceName
}}</span> }}</span>
<div
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div> </div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" /> <i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div> </div>
@@ -92,15 +83,23 @@
> >
{{ $t('subscription.addCredits') }} {{ $t('subscription.addCredits') }}
</Button> </Button>
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) --> <!-- Unsubscribed: Show Subscribe button -->
<SubscribeButton <SubscribeButton
v-else v-else-if="isPersonalWorkspace"
disabled
:fluid="false" :fluid="false"
:label="$t('workspaceSwitcher.subscribe')" :label="$t('workspaceSwitcher.subscribe')"
size="sm" size="sm"
variant="gradient" variant="gradient"
/> />
<!-- Non-personal workspace: Navigate to workspace settings -->
<Button
v-else
variant="primary"
size="sm"
@click="handleOpenPlanAndCreditsSettings"
>
{{ $t('workspaceSwitcher.subscribe') }}
</Button>
</div> </div>
<Divider class="mx-0 my-2" /> <Divider class="mx-0 my-2" />
@@ -198,7 +197,6 @@ import Divider from 'primevue/divider'
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue' import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue' import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
@@ -221,8 +219,7 @@ const workspaceStore = useTeamWorkspaceStore()
const { const {
workspaceName, workspaceName,
isInPersonalWorkspace: isPersonalWorkspace, isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed, isWorkspaceSubscribed
subscriptionPlan
} = storeToRefs(workspaceStore) } = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI() const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null) const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
@@ -240,24 +237,12 @@ const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription() const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits() const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog() const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() => const displayedCredits = computed(() => {
isWorkspaceSubscribed.value ? totalCredits.value : '0' const isSubscribed = isPersonalWorkspace.value
) ? isActiveSubscription.value
: isWorkspaceSubscribed.value
// Workspace subscription tier name (not user tier) return isSubscribed ? totalCredits.value : '0'
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
}) })
const canUpgrade = computed(() => { const canUpgrade = computed(() => {

View File

@@ -38,13 +38,22 @@
:workspace-name="workspace.name" :workspace-name="workspace.name"
/> />
<div class="flex min-w-0 flex-1 flex-col items-start gap-1"> <div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground"> <div class="flex items-center gap-1.5">
{{ workspace.name }} <span class="text-sm text-base-foreground">
</span> {{
<span workspace.type === 'personal'
v-if="workspace.type !== 'personal'" ? $t('workspaceSwitcher.personal')
class="text-sm text-muted-foreground" : workspace.name
> }}
</span>
<span
v-if="getTierLabel(workspace)"
class="text-[10px] font-bold uppercase text-base-background bg-base-foreground px-1 py-0.5 rounded-full"
>
{{ getTierLabel(workspace) }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ getRoleLabel(workspace.role) }} {{ getRoleLabel(workspace.role) }}
</span> </span>
</div> </div>
@@ -58,8 +67,6 @@
</template> </template>
</template> </template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button --> <!-- Create workspace button -->
<div class="px-2 py-2"> <div class="px-2 py-2">
<div <div
@@ -107,19 +114,23 @@ import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue' import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch' import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { import type {
WorkspaceRole, WorkspaceRole,
WorkspaceType WorkspaceType
} from '@/platform/workspace/api/workspaceApi' } from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface AvailableWorkspace { interface AvailableWorkspace {
id: string id: string
name: string name: string
type: WorkspaceType type: WorkspaceType
role: WorkspaceRole role: WorkspaceRole
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -129,6 +140,7 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch() const { switchWithConfirmation } = useWorkspaceSwitch()
const { subscriptionTierName: userSubscriptionTierName } = useSubscription()
const workspaceStore = useTeamWorkspaceStore() const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } = const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
@@ -139,7 +151,9 @@ const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
id: w.id, id: w.id,
name: w.name, name: w.name,
type: w.type, type: w.type,
role: w.role role: w.role,
isSubscribed: w.isSubscribed,
subscriptionPlan: w.subscriptionPlan
})) }))
) )
@@ -153,6 +167,22 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
return '' return ''
} }
function getTierLabel(workspace: AvailableWorkspace): string | null {
// Personal workspace: use user's subscription tier
if (workspace.type === 'personal') {
return userSubscriptionTierName.value || null
}
// Team workspace: use workspace subscription plan
if (!workspace.isSubscribed || !workspace.subscriptionPlan) return null
if (workspace.subscriptionPlan === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (workspace.subscriptionPlan === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) { async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id) const success = await switchWithConfirmation(workspace.id)
if (success) { if (success) {

View File

@@ -1,6 +1,7 @@
{ {
"g": { "g": {
"user": "User", "user": "User",
"you": "You",
"currentUser": "Current user", "currentUser": "Current user",
"empty": "Empty", "empty": "Empty",
"noWorkflowsFound": "No workflows found.", "noWorkflowsFound": "No workflows found.",
@@ -2100,6 +2101,10 @@
"creator": "30 min", "creator": "30 min",
"pro": "1 hr", "pro": "1 hr",
"founder": "30 min" "founder": "30 min"
},
"billingComingSoon": {
"title": "Coming Soon",
"message": "Team billing is coming soon. You'll be able to subscribe to a plan for your workspace with per-seat pricing. Stay tuned for updates."
} }
}, },
"userSettings": { "userSettings": {
@@ -2113,8 +2118,38 @@
"updatePassword": "Update Password" "updatePassword": "Update Password"
}, },
"workspacePanel": { "workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": { "tabs": {
"planCredits": "Plan & Credits" "dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
}, },
"menu": { "menu": {
"editWorkspace": "Edit workspace details", "editWorkspace": "Edit workspace details",
@@ -2137,6 +2172,32 @@
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.", "message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone." "messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
}, },
"removeMemberDialog": {
"title": "Remove this member?",
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
"remove": "Remove member",
"success": "Member removed",
"error": "Failed to remove member"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": { "createWorkspaceDialog": {
"title": "Create a new workspace", "title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.", "message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
@@ -2145,19 +2206,34 @@
"create": "Create" "create": "Create"
}, },
"toast": { "toast": {
"workspaceCreated": {
"title": "Workspace created",
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
"subscribe": "Subscribe"
},
"workspaceUpdated": { "workspaceUpdated": {
"title": "Workspace updated", "title": "Workspace updated",
"message": "Workspace details have been saved." "message": "Workspace details have been saved."
}, },
"workspaceDeleted": {
"title": "Workspace deleted",
"message": "The workspace has been permanently deleted."
},
"workspaceLeft": {
"title": "Left workspace",
"message": "You have left the workspace."
},
"failedToUpdateWorkspace": "Failed to update workspace", "failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace", "failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace", "failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace" "failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
} }
}, },
"workspaceSwitcher": { "workspaceSwitcher": {
"switchWorkspace": "Switch workspace", "switchWorkspace": "Switch workspace",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"personal": "Personal",
"roleOwner": "Owner", "roleOwner": "Owner",
"roleMember": "Member", "roleMember": "Member",
"createWorkspace": "Create new workspace", "createWorkspace": "Create new workspace",
@@ -2710,7 +2786,10 @@
"unsavedChanges": { "unsavedChanges": {
"title": "Unsaved Changes", "title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?" "message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
} },
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
}, },
"workspaceAuth": { "workspaceAuth": {
"errors": { "errors": {
@@ -2721,6 +2800,7 @@
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}" "tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
} }
}, },
"nightly": { "nightly": {
"badge": { "badge": {
"label": "Preview Version", "label": "Preview Version",

View File

@@ -26,14 +26,16 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key t: (key: string) => key
})) }))
const mockRemoteConfig = vi.hoisted(() => ({ const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: true }))
value: {
team_workspaces_enabled: true
}
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({ vi.mock('@/composables/useFeatureFlags', () => ({
remoteConfig: mockRemoteConfig useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
})) }))
const mockWorkspace = { const mockWorkspace = {
@@ -622,11 +624,11 @@ describe('useWorkspaceAuthStore', () => {
describe('feature flag disabled', () => { describe('feature flag disabled', () => {
beforeEach(() => { beforeEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = false mockTeamWorkspacesEnabled.value = false
}) })
afterEach(() => { afterEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = true mockTeamWorkspacesEnabled.value = true
}) })
it('initializeFromSession returns false when flag disabled', () => { it('initializeFromSession returns false when flag disabled', () => {

View File

@@ -14,7 +14,7 @@ const mockSubscriptionTier = ref<
const mockIsYearlySubscription = ref(false) const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn() const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn() const mockReportError = vi.fn()
const mockGetAuthHeader = vi.fn(() => const mockGetFirebaseAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' }) Promise.resolve({ Authorization: 'Bearer test-token' })
) )
@@ -53,7 +53,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({ useFirebaseAuthStore: () => ({
getAuthHeader: mockGetAuthHeader getFirebaseAuthHeader: mockGetFirebaseAuthHeader
}), }),
FirebaseAuthStoreError: class extends Error {} FirebaseAuthStoreError: class extends Error {}
})) }))

View File

@@ -68,7 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import { defineAsyncComponent } from 'vue' import { computed, defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue' import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
@@ -85,7 +85,9 @@ const SubscriptionPanelContentWorkspace = defineAsyncComponent(
) )
const { flags } = useFeatureFlags() const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const { buildDocsUrl, docsPaths } = useExternalLink() const { buildDocsUrl, docsPaths } = useExternalLink()

View File

@@ -1,10 +1,12 @@
<template> <template>
<div class="grow overflow-auto"> <div class="grow overflow-auto pt-6">
<div class="rounded-2xl border border-interface-stroke p-6"> <div class="rounded-2xl border border-interface-stroke p-6">
<div> <div>
<div class="flex items-center justify-between gap-2"> <div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
>
<!-- OWNER Unsubscribed State --> <!-- OWNER Unsubscribed State -->
<template v-if="isOwnerUnsubscribed"> <template v-if="showSubscribePrompt">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary"> <div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }} {{ $t('subscription.workspaceNotSubscribed') }}
@@ -15,6 +17,7 @@
</div> </div>
<Button <Button
variant="primary" variant="primary"
size="lg"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal" class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace" @click="handleSubscribeWorkspace"
> >
@@ -65,12 +68,14 @@
</div> </div>
</div> </div>
<template <div
v-if="isActiveSubscription && permissions.canManageSubscription" v-if="isActiveSubscription && permissions.canManageSubscription"
class="flex flex-wrap gap-2 md:ml-auto"
> >
<Button <Button
size="lg"
variant="secondary" variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected" class="rounded-lg px-4 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click=" @click="
async () => { async () => {
await authActions.accessBillingPortal() await authActions.accessBillingPortal()
@@ -80,23 +85,24 @@
{{ $t('subscription.managePayment') }} {{ $t('subscription.managePayment') }}
</Button> </Button>
<Button <Button
size="lg"
variant="primary" variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary" class="rounded-lg px-4 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog" @click="showSubscriptionDialog"
> >
{{ $t('subscription.upgradePlan') }} {{ $t('subscription.upgradePlan') }}
</Button> </Button>
<Button <Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }" v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly" variant="secondary"
size="icon" size="lg"
:aria-label="$t('g.moreOptions')" :aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)" @click="planMenu?.toggle($event)"
> >
<i class="pi pi-ellipsis-h" /> <i class="pi pi-ellipsis-h" />
</Button> </Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" /> <Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template> </div>
</template> </template>
</div> </div>
</div> </div>
@@ -247,6 +253,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions' import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits' import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
@@ -264,26 +271,34 @@ import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions() const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore() const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore) const { isWorkspaceSubscribed, isInPersonalWorkspace } =
storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI() const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n() const { t, n } = useI18n()
const { showBillingComingSoonDialog } = useDialogService()
// OWNER with unsubscribed workspace - can see subscribe button // Show subscribe prompt to owners without active subscription
const isOwnerUnsubscribed = computed( const showSubscribePrompt = computed(() => {
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value if (workspaceRole.value !== 'owner') return false
) if (isInPersonalWorkspace.value) return !isActiveSubscription.value
return !isWorkspaceSubscribed.value
})
// MEMBER view - members can't manage subscription, show read-only zero state // MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription) const isMemberView = computed(() => !permissions.value.canManageSubscription)
// Show zero state for credits (no real billing data yet) // Show zero state for credits (no real billing data yet)
const showZeroState = computed( const showZeroState = computed(
() => isOwnerUnsubscribed.value || isMemberView.value () => showSubscribePrompt.value || isMemberView.value
) )
// Demo: Subscribe workspace to PRO monthly plan // Subscribe workspace - show billing coming soon dialog for team workspaces
function handleSubscribeWorkspace() { function handleSubscribeWorkspace() {
if (!isInPersonalWorkspace.value) {
showBillingComingSoonDialog()
return
}
subscribeWorkspace('PRO_MONTHLY') subscribeWorkspace('PRO_MONTHLY')
} }

View File

@@ -35,8 +35,8 @@ export async function performSubscriptionCheckout(
): Promise<void> { ): Promise<void> {
if (!isCloud) return if (!isCloud) return
const { getAuthHeader } = useFirebaseAuthStore() const { getFirebaseAuthHeader } = useFirebaseAuthStore()
const authHeader = await getAuthHeader() const authHeader = await getFirebaseAuthHeader()
if (!authHeader) { if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated')) throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))

View File

@@ -56,7 +56,9 @@ const writeToStorage = (
} }
export const hydratePreservedQuery = (namespace: string) => { export const hydratePreservedQuery = (namespace: string) => {
if (preservedQueries.has(namespace)) return if (preservedQueries.has(namespace)) {
return
}
const payload = readFromStorage(namespace) const payload = readFromStorage(namespace)
if (payload) { if (payload) {
preservedQueries.set(namespace, payload) preservedQueries.set(namespace, payload)
@@ -77,7 +79,9 @@ export const capturePreservedQuery = (
} }
}) })
if (Object.keys(payload).length === 0) return if (Object.keys(payload).length === 0) {
return
}
preservedQueries.set(namespace, payload) preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload) writeToStorage(namespace, payload)

View File

@@ -1,3 +1,4 @@
export const PRESERVED_QUERY_NAMESPACES = { export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template' TEMPLATE: 'template',
INVITE: 'invite'
} as const } as const

View File

@@ -2,25 +2,27 @@
<div <div
:class=" :class="
teamWorkspacesEnabled teamWorkspacesEnabled
? 'flex h-[80vh] w-full overflow-hidden' ? 'flex h-full w-full overflow-auto flex-col md:flex-row'
: 'settings-container' : 'settings-container'
" "
> >
<ScrollPanel <ScrollPanel
:class=" :class="
teamWorkspacesEnabled teamWorkspacesEnabled
? 'w-48 shrink-0 p-2 2xl:w-64' ? 'w-full md:w-64 md:min-w-64 md:max-w-64 shrink-0 p-2'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64' : 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
" "
> >
<SearchBox <div :class="teamWorkspacesEnabled ? 'px-4' : ''">
v-model:model-value="searchQuery" <SearchBox
class="settings-search-box mb-2 w-full" v-model:model-value="searchQuery"
:placeholder="$t('g.searchSettings') + '...'" class="settings-search-box mb-2 w-full"
:debounce-time="128" :placeholder="$t('g.searchSettings') + '...'"
autofocus :debounce-time="128"
@search="handleSearch" autofocus
/> @search="handleSearch"
/>
</div>
<Listbox <Listbox
v-model="activeCategory" v-model="activeCategory"
:options="groupedMenuTreeNodes" :options="groupedMenuTreeNodes"
@@ -62,7 +64,7 @@
:lazy="true" :lazy="true"
:class=" :class="
teamWorkspacesEnabled teamWorkspacesEnabled
? 'h-full flex-1 overflow-x-auto' ? 'h-full flex-1 overflow-auto scrollbar-custom'
: 'settings-content h-full w-full' : 'settings-content h-full w-full'
" "
> >

View File

@@ -22,6 +22,7 @@ export interface Member {
name: string name: string
email: string email: string
joined_at: string joined_at: string
role: WorkspaceRole
} }
interface PaginationInfo { interface PaginationInfo {
@@ -110,6 +111,18 @@ async function getAuthHeaderOrThrow() {
return authHeader return authHeader
} }
async function getFirebaseHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never { function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const status = err.response?.status const status = err.response?.status
@@ -296,9 +309,10 @@ export const workspaceApi = {
/** /**
* Accept a workspace invite. * Accept a workspace invite.
* POST /api/invites/:token/accept * POST /api/invites/:token/accept
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
*/ */
async acceptInvite(token: string): Promise<AcceptInviteResponse> { async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await getAuthHeaderOrThrow() const headers = await getFirebaseHeaderOrThrow()
try { try {
const response = await workspaceApiClient.post<AcceptInviteResponse>( const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`), api.apiURL(`/invites/${token}/accept`),

View File

@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInviteUrlLoader } from './useInviteUrlLoader'
/**
* Unit tests for useInviteUrlLoader composable
*
* Tests the behavior of accepting workspace invites via URL query parameters:
* - ?invite=TOKEN accepts the invite and shows success toast
* - Invalid/missing token is handled gracefully
* - API errors show error toast
* - URL is cleaned up after processing
* - Preserved query is restored after login redirect
*/
const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))
vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)
const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))
vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key
}
}),
useI18n: () => ({
t: vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
if (key === 'workspace.addedToWorkspace') {
return `You have been added to ${params?.workspaceName}`
}
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
if (key === 'g.unknownError') return 'Unknown error'
return key
})
})
}))
const mockAcceptInvite = vi.hoisted(() => vi.fn())
vi.mock('../stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
acceptInvite: mockAcceptInvite
})
}))
describe('useInviteUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
mockRouteQuery.value = {}
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})
it('restores preserved query and processes invite', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
})
it('accepts invite and shows success toast on success', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
})
it('shows error toast when invite acceptance fails', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
})
})
it('cleans up URL after processing invite', async () => {
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Should replace with query without invite param
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})
it('clears preserved query after processing', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('clears preserved query even on error', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})
it('sends any token format to backend for validation', async () => {
mockRouteQuery.value = { invite: 'any-token-format==' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
// Token is sent to backend, which validates and rejects
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
})
})
it('ignores empty invite param', async () => {
mockRouteQuery.value = { invite: '' }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
it('ignores non-string invite param', async () => {
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()
expect(mockAcceptInvite).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,107 @@
import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
clearPreservedQuery,
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/**
* Composable for loading workspace invites from URL query parameters
*
* Supports URLs like:
* - /?invite=TOKEN (accepts workspace invite)
*
* The invite token is preserved through login redirects via the
* preserved query system (sessionStorage), following the same pattern
* as the template URL loader.
*/
export function useInviteUrlLoader() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
/**
* Hydrates preserved query from sessionStorage and merges into route.
* This restores the invite token after login redirects.
*/
const ensureInviteQueryFromIntent = async () => {
hydratePreservedQuery(INVITE_NAMESPACE)
const mergedQuery = mergePreservedQueryIntoQuery(
INVITE_NAMESPACE,
route.query
)
if (mergedQuery) {
await router.replace({ query: mergedQuery })
}
return mergedQuery ?? route.query
}
/**
* Removes invite parameter from URL using Vue Router
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.invite
void router.replace({ query: newQuery })
}
/**
* Loads and accepts workspace invite from URL query parameters if present.
* Handles errors internally and shows appropriate user feedback.
*
* Flow:
* 1. Restore preserved query (for post-login redirect)
* 2. Check for invite token in route.query
* 3. Accept the invite via API (backend validates token)
* 4. Show toast notification
* 5. Clean up URL and preserved query
*/
const loadInviteFromUrl = async () => {
// Restore preserved query from sessionStorage (handles login redirect case)
const query = await ensureInviteQueryFromIntent()
const inviteParam = query.invite
if (!inviteParam || typeof inviteParam !== 'string') {
return
}
try {
const result = await workspaceStore.acceptInvite(inviteParam)
toast.add({
severity: 'success',
summary: t('workspace.inviteAccepted'),
detail: t(
'workspace.addedToWorkspace',
{ workspaceName: result.workspaceName },
{ escapeParameter: false }
),
life: 5000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
cleanupUrlParams()
clearPreservedQuery(INVITE_NAMESPACE)
}
}
return {
loadInviteFromUrl
}
}

View File

@@ -6,6 +6,11 @@ import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */ /** Permission flags for workspace actions */
interface WorkspacePermissions { interface WorkspacePermissions {
canViewOtherMembers: boolean
canViewPendingInvites: boolean
canInviteMembers: boolean
canManageInvites: boolean
canRemoveMembers: boolean
canLeaveWorkspace: boolean canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean canAccessWorkspaceMenu: boolean
canManageSubscription: boolean canManageSubscription: boolean
@@ -13,6 +18,14 @@ interface WorkspacePermissions {
/** UI configuration for workspace role */ /** UI configuration for workspace role */
interface WorkspaceUIConfig { interface WorkspaceUIConfig {
showMembersList: boolean
showPendingTab: boolean
showSearch: boolean
showDateColumn: boolean
showRoleBadge: boolean
membersGridCols: string
pendingGridCols: string
headerGridCols: string
showEditWorkspaceMenuItem: boolean showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null workspaceMenuDisabledTooltip: string | null
@@ -24,6 +37,11 @@ function getPermissions(
): WorkspacePermissions { ): WorkspacePermissions {
if (type === 'personal') { if (type === 'personal') {
return { return {
canViewOtherMembers: false,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: false, canLeaveWorkspace: false,
canAccessWorkspaceMenu: false, canAccessWorkspaceMenu: false,
canManageSubscription: true canManageSubscription: true
@@ -32,6 +50,11 @@ function getPermissions(
if (role === 'owner') { if (role === 'owner') {
return { return {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canRemoveMembers: true,
canLeaveWorkspace: true, canLeaveWorkspace: true,
canAccessWorkspaceMenu: true, canAccessWorkspaceMenu: true,
canManageSubscription: true canManageSubscription: true
@@ -40,6 +63,11 @@ function getPermissions(
// member role // member role
return { return {
canViewOtherMembers: true,
canViewPendingInvites: false,
canInviteMembers: false,
canManageInvites: false,
canRemoveMembers: false,
canLeaveWorkspace: true, canLeaveWorkspace: true,
canAccessWorkspaceMenu: true, canAccessWorkspaceMenu: true,
canManageSubscription: false canManageSubscription: false
@@ -52,6 +80,14 @@ function getUIConfig(
): WorkspaceUIConfig { ): WorkspaceUIConfig {
if (type === 'personal') { if (type === 'personal') {
return { return {
showMembersList: false,
showPendingTab: false,
showSearch: false,
showDateColumn: false,
showRoleBadge: false,
membersGridCols: 'grid-cols-1',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-1',
showEditWorkspaceMenuItem: false, showEditWorkspaceMenuItem: false,
workspaceMenuAction: null, workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null workspaceMenuDisabledTooltip: null
@@ -60,6 +96,14 @@ function getUIConfig(
if (role === 'owner') { if (role === 'owner') {
return { return {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true, showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete', workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip: workspaceMenuDisabledTooltip:
@@ -69,6 +113,14 @@ function getUIConfig(
// member role // member role
return { return {
showMembersList: true,
showPendingTab: false,
showSearch: true,
showDateColumn: true,
showRoleBadge: true,
membersGridCols: 'grid-cols-[1fr_auto]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[1fr_auto]',
showEditWorkspaceMenuItem: false, showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave', workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null workspaceMenuDisabledTooltip: null

View File

@@ -2,6 +2,8 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue' import { computed, ref, shallowRef } from 'vue'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore' import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
import type { import type {
@@ -12,14 +14,15 @@ import type {
} from '../api/workspaceApi' } from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi' import { workspaceApi } from '../api/workspaceApi'
interface WorkspaceMember { export interface WorkspaceMember {
id: string id: string
name: string name: string
email: string email: string
joinDate: Date joinDate: Date
role: 'owner' | 'member'
} }
interface PendingInvite { export interface PendingInvite {
id: string id: string
email: string email: string
token: string token: string
@@ -43,7 +46,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
id: member.id, id: member.id,
name: member.name, name: member.name,
email: member.email, email: member.email,
joinDate: new Date(member.joined_at) joinDate: new Date(member.joined_at),
role: member.role
} }
} }
@@ -60,7 +64,8 @@ function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState { function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return { return {
...workspace, ...workspace,
isSubscribed: false, // Personal workspaces use user-scoped subscription from useSubscription()
isSubscribed: workspace.type === 'personal',
subscriptionPlan: null, subscriptionPlan: null,
members: [], members: [],
pendingInvites: [] pendingInvites: []
@@ -367,6 +372,9 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
// Clear context and switch to new workspace // Clear context and switch to new workspace
workspaceAuthStore.clearWorkspaceContext() workspaceAuthStore.clearWorkspaceContext()
// Clear any preserved invite query to prevent stale invites from being
// processed after the reload (prevents owner adding themselves as member)
clearPreservedQuery(PRESERVED_QUERY_NAMESPACES.INVITE)
setLastWorkspaceId(newWorkspace.id) setLastWorkspaceId(newWorkspace.id)
window.location.reload() window.location.reload()

View File

@@ -86,6 +86,10 @@ installPreservedQueryTracker(router, [
{ {
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE, namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
keys: ['template', 'source', 'mode'] keys: ['template', 'source', 'mode']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
keys: ['invite']
} }
]) ])

View File

@@ -41,6 +41,7 @@ export type ConfirmationDialogType =
| 'delete' | 'delete'
| 'dirtyClose' | 'dirtyClose'
| 'reinstall' | 'reinstall'
| 'info'
/** /**
* Minimal interface for execution error dialogs. * Minimal interface for execution error dialogs.
@@ -589,6 +590,62 @@ export const useDialogService = () => {
}) })
} }
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogPt
})
}
async function showInviteMemberDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
return dialogStore.showDialog({
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogPt
})
}
function showBillingComingSoonDialog() {
return dialogStore.showDialog({
key: 'billing-coming-soon',
title: t('subscription.billingComingSoon.title'),
component: ConfirmationDialogContent,
props: {
message: t('subscription.billingComingSoon.message'),
type: 'info' as ConfirmationDialogType,
onConfirm: () => {}
},
dialogComponentProps: {
pt: {
root: { class: 'max-w-[360px]' }
}
}
})
}
return { return {
showLoadWorkflowWarning, showLoadWorkflowWarning,
showMissingModelsWarning, showMissingModelsWarning,
@@ -610,6 +667,10 @@ export const useDialogService = () => {
showDeleteWorkspaceDialog, showDeleteWorkspaceDialog,
showCreateWorkspaceDialog, showCreateWorkspaceDialog,
showLeaveWorkspaceDialog, showLeaveWorkspaceDialog,
showEditWorkspaceDialog showEditWorkspaceDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showBillingComingSoonDialog
} }
} }

View File

@@ -8,11 +8,11 @@ import {
TOKEN_REFRESH_BUFFER_MS, TOKEN_REFRESH_BUFFER_MS,
WORKSPACE_STORAGE_KEYS WORKSPACE_STORAGE_KEYS
} from '@/platform/auth/workspace/workspaceConstants' } from '@/platform/auth/workspace/workspaceConstants'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes' import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes' import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const WorkspaceWithRoleSchema = z.object({ const WorkspaceWithRoleSchema = z.object({
id: z.string(), id: z.string(),
@@ -44,6 +44,8 @@ export class WorkspaceAuthError extends Error {
} }
export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
const { flags } = useFeatureFlags()
// State // State
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null) const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null) const workspaceToken = ref<string | null>(null)
@@ -120,7 +122,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
} }
function initializeFromSession(): boolean { function initializeFromSession(): boolean {
if (!remoteConfig.value.team_workspaces_enabled) { if (!flags.teamWorkspacesEnabled) {
return false return false
} }
@@ -164,7 +166,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
} }
async function switchWorkspace(workspaceId: string): Promise<void> { async function switchWorkspace(workspaceId: string): Promise<void> {
if (!remoteConfig.value.team_workspaces_enabled) { if (!flags.teamWorkspacesEnabled) {
return return
} }