mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 18:22:40 +00:00
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:
@@ -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)'
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal file
511
src/components/dialog/content/setting/MembersPanelContent.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export const PRESERVED_QUERY_NAMESPACES = {
|
export const PRESERVED_QUERY_NAMESPACES = {
|
||||||
TEMPLATE: 'template'
|
TEMPLATE: 'template',
|
||||||
|
INVITE: 'invite'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -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'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
107
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
107
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user