mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 09:15:52 +00:00
Compare commits
3 Commits
coderabbit
...
feat/works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d56d39223 | ||
|
|
3940cc5a9c | ||
|
|
a0dc6432fc |
43
src/components/common/WorkspaceProfilePic.vue
Normal file
43
src/components/common/WorkspaceProfilePic.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
workspaceName: string
|
||||
}>()
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
const seed = letter.value.charCodeAt(0)
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5)
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const rand = mulberry32(seed)
|
||||
|
||||
const hue1 = Math.floor(rand() * 360)
|
||||
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
|
||||
const sat = 65 + Math.floor(rand() * 20)
|
||||
const light = 55 + Math.floor(rand() * 15)
|
||||
|
||||
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] 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.createWorkspaceDialog.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="flex flex-col gap-4 px-4 py-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="workspaceName"
|
||||
type="text"
|
||||
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.createWorkspaceDialog.namePlaceholder')
|
||||
"
|
||||
@keydown.enter="isValidName && onCreate()"
|
||||
/>
|
||||
</div>
|
||||
</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="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</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 { onConfirm } = defineProps<{
|
||||
onConfirm?: (name: string) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const workspaceName = ref('')
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const name = workspaceName.value.trim()
|
||||
// Call optional callback if provided
|
||||
await onConfirm?.(name)
|
||||
dialogStore.closeDialog({ key: 'create-workspace' })
|
||||
// Create workspace and switch to it (triggers reload internally)
|
||||
await workspaceStore.createWorkspace(name)
|
||||
} catch (error) {
|
||||
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<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.deleteDialog.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">
|
||||
{{
|
||||
workspaceName
|
||||
? $t('workspacePanel.deleteDialog.messageWithName', {
|
||||
name: workspaceName
|
||||
})
|
||||
: $t('workspacePanel.deleteDialog.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="onDelete">
|
||||
{{ $t('g.delete') }}
|
||||
</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 { workspaceId, workspaceName } = defineProps<{
|
||||
workspaceId?: string
|
||||
workspaceName?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
|
||||
await workspaceStore.deleteWorkspace(workspaceId)
|
||||
dialogStore.closeDialog({ key: 'delete-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] 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.editWorkspaceDialog.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="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newWorkspaceName"
|
||||
type="text"
|
||||
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"
|
||||
@keydown.enter="isValidName && onSave()"
|
||||
/>
|
||||
</div>
|
||||
</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="primary"
|
||||
size="lg"
|
||||
:loading
|
||||
:disabled="!isValidName"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</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 { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!isValidName.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
|
||||
dialogStore.closeDialog({ key: 'edit-workspace' })
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.toast.workspaceUpdated.title'),
|
||||
detail: t('workspacePanel.toast.workspaceUpdated.message'),
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<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'
|
||||
} 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,78 @@
|
||||
<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.leaveDialog.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.leaveDialog.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="onLeave">
|
||||
{{ $t('workspacePanel.leaveDialog.leave') }}
|
||||
</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 { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
}
|
||||
|
||||
async function onLeave() {
|
||||
loading.value = true
|
||||
try {
|
||||
// leaveWorkspace() handles switching to personal workspace internally and reloads
|
||||
await workspaceStore.leaveWorkspace()
|
||||
dialogStore.closeDialog({ key: 'leave-workspace' })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<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 { ref } from 'vue'
|
||||
|
||||
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 loading = ref(false)
|
||||
|
||||
function onCancel() {
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
loading.value = true
|
||||
try {
|
||||
await workspaceStore.removeMember(memberId)
|
||||
dialogStore.closeDialog({ key: 'remove-member' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<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 { ref } from 'vue'
|
||||
|
||||
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 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' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,22 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
||||
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
|
||||
const mockCurrentWorkspace = vi.hoisted(() => ({
|
||||
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||
value: null as WorkspaceWithRole | null
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceAuthStore', () => ({
|
||||
useWorkspaceAuthStore: () => ({
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
switchWorkspace: mockSwitchWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: () => ({
|
||||
currentWorkspace: mockCurrentWorkspace
|
||||
activeWorkspace: mockActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockReload = vi.fn()
|
||||
|
||||
describe('useWorkspaceSwitch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCurrentWorkspace.value = {
|
||||
mockActiveWorkspace.value = {
|
||||
id: 'workspace-1',
|
||||
name: 'Test Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
mockModifiedWorkflows.length = 0
|
||||
vi.stubGlobal('location', { reload: mockReload })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
|
||||
expect(result).toBe(true)
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||
expect(mockReload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows confirmation dialog when there are unsaved changes', async () => {
|
||||
@@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
||||
expect(mockReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls switchWorkspace and reloads page after user confirms', async () => {
|
||||
it('calls switchWorkspace after user confirms', async () => {
|
||||
mockModifiedWorkflows.push({ isModified: true })
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||
@@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||
expect(mockReload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false if switchWorkspace throws an error', async () => {
|
||||
@@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
|
||||
const result = await switchWithConfirmation('workspace-2')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockReload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
||||
|
||||
export function useWorkspaceSwitch() {
|
||||
const { t } = useI18n()
|
||||
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { activeWorkspace } = storeToRefs(workspaceStore)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
|
||||
}
|
||||
|
||||
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
|
||||
if (currentWorkspace.value?.id === workspaceId) {
|
||||
if (activeWorkspace.value?.id === workspaceId) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceAuthStore.switchWorkspace(workspaceId)
|
||||
window.location.reload()
|
||||
await workspaceStore.switchWorkspace(workspaceId)
|
||||
// Note: switchWorkspace triggers page reload internally
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export const WORKSPACE_STORAGE_KEYS = {
|
||||
// sessionStorage keys (cleared on browser close)
|
||||
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
|
||||
TOKEN: 'Comfy.Workspace.Token',
|
||||
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
|
||||
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt',
|
||||
// localStorage key (persists across browser sessions)
|
||||
LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId'
|
||||
} as const
|
||||
|
||||
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template'
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
} as const
|
||||
|
||||
334
src/platform/workspace/api/workspaceApi.ts
Normal file
334
src/platform/workspace/api/workspaceApi.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import axios from 'axios'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
|
||||
// Types aligned with backend API
|
||||
export type WorkspaceType = 'personal' | 'team'
|
||||
export type WorkspaceRole = 'owner' | 'member'
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkspaceType
|
||||
}
|
||||
|
||||
export interface WorkspaceWithRole extends Workspace {
|
||||
role: WorkspaceRole
|
||||
}
|
||||
|
||||
// Member type from API
|
||||
export interface Member {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
offset: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
interface ListMembersResponse {
|
||||
members: Member[]
|
||||
pagination: PaginationInfo
|
||||
}
|
||||
|
||||
export interface ListMembersParams {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Pending invite type from API
|
||||
export interface PendingInvite {
|
||||
id: string
|
||||
email: string
|
||||
token: string
|
||||
invited_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface ListInvitesResponse {
|
||||
invites: PendingInvite[]
|
||||
}
|
||||
|
||||
interface CreateInviteRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
interface AcceptInviteResponse {
|
||||
workspace_id: string
|
||||
workspace_name: string
|
||||
}
|
||||
|
||||
// Billing types (POST /api/billing/portal)
|
||||
interface BillingPortalRequest {
|
||||
return_url: string
|
||||
}
|
||||
|
||||
interface BillingPortalResponse {
|
||||
billing_portal_url: string
|
||||
}
|
||||
|
||||
interface CreateWorkspacePayload {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface UpdateWorkspacePayload {
|
||||
name: string
|
||||
}
|
||||
|
||||
// API responses
|
||||
interface ListWorkspacesResponse {
|
||||
workspaces: WorkspaceWithRole[]
|
||||
}
|
||||
|
||||
// Token exchange types (POST /api/auth/token)
|
||||
interface ExchangeTokenRequest {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
export interface ExchangeTokenResponse {
|
||||
token: string
|
||||
expires_at: string
|
||||
workspace: Workspace
|
||||
role: WorkspaceRole
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export class WorkspaceApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
public readonly code?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WorkspaceApiError'
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceApiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
async function withAuth<T>(
|
||||
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
|
||||
): Promise<T> {
|
||||
const authHeader = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const response = await request(authHeader)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const message = err.response?.data?.message ?? err.message
|
||||
throw new WorkspaceApiError(message, status)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that uses Firebase ID token directly (not workspace token).
|
||||
* Used for token exchange where we need the Firebase token to get a workspace token.
|
||||
*/
|
||||
async function withFirebaseAuth<T>(
|
||||
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
|
||||
): Promise<T> {
|
||||
const firebaseToken = await useFirebaseAuthStore().getIdToken()
|
||||
if (!firebaseToken) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
const headers: AuthHeader = { Authorization: `Bearer ${firebaseToken}` }
|
||||
try {
|
||||
const response = await request(headers)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const message = err.response?.data?.message ?? err.message
|
||||
const code =
|
||||
status === 401
|
||||
? 'INVALID_FIREBASE_TOKEN'
|
||||
: status === 403
|
||||
? 'ACCESS_DENIED'
|
||||
: status === 404
|
||||
? 'WORKSPACE_NOT_FOUND'
|
||||
: 'TOKEN_EXCHANGE_FAILED'
|
||||
throw new WorkspaceApiError(message, status, code)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export const workspaceApi = {
|
||||
/**
|
||||
* List all workspaces the user has access to
|
||||
* GET /api/workspaces
|
||||
*/
|
||||
list: (): Promise<ListWorkspacesResponse> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.get(api.apiURL('/workspaces'), { headers })
|
||||
),
|
||||
|
||||
/**
|
||||
* Create a new workspace
|
||||
* POST /api/workspaces
|
||||
*/
|
||||
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers })
|
||||
),
|
||||
|
||||
/**
|
||||
* Update workspace name
|
||||
* PATCH /api/workspaces/:id
|
||||
*/
|
||||
update: (
|
||||
workspaceId: string,
|
||||
payload: UpdateWorkspacePayload
|
||||
): Promise<WorkspaceWithRole> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.patch(
|
||||
api.apiURL(`/workspaces/${workspaceId}`),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Delete a workspace (owner only)
|
||||
* DELETE /api/workspaces/:id
|
||||
*/
|
||||
delete: (workspaceId: string): Promise<void> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), {
|
||||
headers
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* Leave the current workspace.
|
||||
* POST /api/workspace/leave
|
||||
*/
|
||||
leave: (): Promise<void> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers })
|
||||
),
|
||||
|
||||
/**
|
||||
* List workspace members (paginated).
|
||||
* GET /api/workspace/members
|
||||
*/
|
||||
listMembers: (params?: ListMembersParams): Promise<ListMembersResponse> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.get(api.apiURL('/workspace/members'), {
|
||||
headers,
|
||||
params
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* Remove a member from the workspace.
|
||||
* DELETE /api/workspace/members/:userId
|
||||
*/
|
||||
removeMember: (userId: string): Promise<void> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), {
|
||||
headers
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* List pending invites for the workspace.
|
||||
* GET /api/workspace/invites
|
||||
*/
|
||||
listInvites: (): Promise<ListInvitesResponse> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers })
|
||||
),
|
||||
|
||||
/**
|
||||
* Create an invite for the workspace.
|
||||
* POST /api/workspace/invites
|
||||
*/
|
||||
createInvite: (payload: CreateInviteRequest): Promise<PendingInvite> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, {
|
||||
headers
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* Revoke a pending invite.
|
||||
* DELETE /api/workspace/invites/:inviteId
|
||||
*/
|
||||
revokeInvite: (inviteId: string): Promise<void> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.delete(api.apiURL(`/workspace/invites/${inviteId}`), {
|
||||
headers
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* Accept a workspace invite.
|
||||
* POST /api/invites/:token/accept
|
||||
*/
|
||||
acceptInvite: (token: string): Promise<AcceptInviteResponse> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.post(api.apiURL(`/invites/${token}/accept`), null, {
|
||||
headers
|
||||
})
|
||||
),
|
||||
|
||||
/**
|
||||
* Exchange Firebase JWT for workspace-scoped Cloud JWT.
|
||||
* POST /api/auth/token
|
||||
*
|
||||
* Uses Firebase ID token directly (not getAuthHeader) since we're
|
||||
* exchanging it for a workspace-scoped token.
|
||||
*/
|
||||
exchangeToken: (workspaceId: string): Promise<ExchangeTokenResponse> =>
|
||||
withFirebaseAuth((headers) =>
|
||||
workspaceApiClient.post(
|
||||
api.apiURL('/auth/token'),
|
||||
{ workspace_id: workspaceId } satisfies ExchangeTokenRequest,
|
||||
{ headers }
|
||||
)
|
||||
),
|
||||
|
||||
/**
|
||||
* Access the billing portal for the current workspace.
|
||||
* POST /api/billing/portal
|
||||
*
|
||||
* Uses workspace-scoped token to get billing portal URL.
|
||||
*/
|
||||
accessBillingPortal: (returnUrl?: string): Promise<BillingPortalResponse> =>
|
||||
withAuth((headers) =>
|
||||
workspaceApiClient.post(
|
||||
api.apiURL('/billing/portal'),
|
||||
{
|
||||
return_url: returnUrl ?? window.location.href
|
||||
} satisfies BillingPortalRequest,
|
||||
{ headers }
|
||||
)
|
||||
)
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
106
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
106
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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
|
||||
}),
|
||||
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
|
||||
}
|
||||
}
|
||||
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
|
||||
|
||||
/** Permission flags for workspace actions */
|
||||
interface WorkspacePermissions {
|
||||
canViewOtherMembers: boolean
|
||||
canViewPendingInvites: boolean
|
||||
canInviteMembers: boolean
|
||||
canManageInvites: boolean
|
||||
canRemoveMembers: boolean
|
||||
canLeaveWorkspace: boolean
|
||||
canAccessWorkspaceMenu: boolean
|
||||
canManageSubscription: boolean
|
||||
}
|
||||
|
||||
/** UI configuration for workspace role */
|
||||
interface WorkspaceUIConfig {
|
||||
showMembersList: boolean
|
||||
showPendingTab: boolean
|
||||
showSearch: boolean
|
||||
showDateColumn: boolean
|
||||
showRoleBadge: boolean
|
||||
membersGridCols: string
|
||||
pendingGridCols: string
|
||||
headerGridCols: string
|
||||
showEditWorkspaceMenuItem: boolean
|
||||
workspaceMenuAction: 'leave' | 'delete' | null
|
||||
workspaceMenuDisabledTooltip: string | null
|
||||
}
|
||||
|
||||
function getPermissions(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspacePermissions {
|
||||
if (type === 'personal') {
|
||||
return {
|
||||
canViewOtherMembers: false,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: false,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canRemoveMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canRemoveMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false
|
||||
}
|
||||
}
|
||||
|
||||
function getUIConfig(
|
||||
type: WorkspaceType,
|
||||
role: WorkspaceRole
|
||||
): WorkspaceUIConfig {
|
||||
if (type === 'personal') {
|
||||
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: true,
|
||||
workspaceMenuAction: null,
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
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,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip:
|
||||
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
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,
|
||||
workspaceMenuAction: 'leave',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation of UI configuration composable.
|
||||
*/
|
||||
function useWorkspaceUIInternal() {
|
||||
const store = useTeamWorkspaceStore()
|
||||
|
||||
// Tab management (shared UI state)
|
||||
const activeTab = ref<string>('plan')
|
||||
|
||||
function setActiveTab(tab: string | number) {
|
||||
activeTab.value = String(tab)
|
||||
}
|
||||
|
||||
const workspaceType = computed<WorkspaceType>(
|
||||
() => store.activeWorkspace?.type ?? 'personal'
|
||||
)
|
||||
|
||||
const workspaceRole = computed<WorkspaceRole>(
|
||||
() => store.activeWorkspace?.role ?? 'owner'
|
||||
)
|
||||
|
||||
const permissions = computed<WorkspacePermissions>(() =>
|
||||
getPermissions(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||
getUIConfig(workspaceType.value, workspaceRole.value)
|
||||
)
|
||||
|
||||
return {
|
||||
// Tab management
|
||||
activeTab: computed(() => activeTab.value),
|
||||
setActiveTab,
|
||||
|
||||
// Permissions and config
|
||||
permissions,
|
||||
uiConfig,
|
||||
workspaceType,
|
||||
workspaceRole
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI configuration composable derived from workspace state.
|
||||
* Controls what UI elements are visible/enabled based on role and workspace type.
|
||||
* Uses createSharedComposable to ensure tab state is shared across components.
|
||||
*/
|
||||
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)
|
||||
148
src/platform/workspace/services/sessionManager.ts
Normal file
148
src/platform/workspace/services/sessionManager.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
|
||||
/**
|
||||
* Session manager for workspace context.
|
||||
* Handles sessionStorage operations and page reloads for workspace switching.
|
||||
*/
|
||||
export const sessionManager = {
|
||||
/**
|
||||
* Get the current workspace ID from sessionStorage
|
||||
*/
|
||||
getCurrentWorkspaceId(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the current workspace ID in sessionStorage
|
||||
*/
|
||||
setCurrentWorkspaceId(workspaceId: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
|
||||
workspaceId
|
||||
)
|
||||
} catch {
|
||||
console.warn('Failed to set workspace ID in sessionStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current workspace ID from sessionStorage
|
||||
*/
|
||||
clearCurrentWorkspaceId(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
} catch {
|
||||
console.warn('Failed to clear workspace ID from sessionStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the last workspace ID from localStorage (cross-session persistence)
|
||||
*/
|
||||
getLastWorkspaceId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist the last workspace ID to localStorage
|
||||
*/
|
||||
setLastWorkspaceId(workspaceId: string): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID,
|
||||
workspaceId
|
||||
)
|
||||
} catch {
|
||||
console.warn('Failed to persist last workspace ID to localStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the last workspace ID from localStorage
|
||||
*/
|
||||
clearLastWorkspaceId(): void {
|
||||
try {
|
||||
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
|
||||
} catch {
|
||||
console.warn('Failed to clear last workspace ID from localStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the workspace token and expiry from sessionStorage
|
||||
*/
|
||||
getWorkspaceToken(): { token: string; expiresAt: number } | null {
|
||||
try {
|
||||
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||
const expiresAtStr = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
||||
)
|
||||
if (!token || !expiresAtStr) return null
|
||||
|
||||
const expiresAt = parseInt(expiresAtStr, 10)
|
||||
if (isNaN(expiresAt)) return null
|
||||
|
||||
return { token, expiresAt }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Store the workspace token and expiry in sessionStorage
|
||||
*/
|
||||
setWorkspaceToken(token: string, expiresAt: number): void {
|
||||
try {
|
||||
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
|
||||
sessionStorage.setItem(
|
||||
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
|
||||
expiresAt.toString()
|
||||
)
|
||||
} catch {
|
||||
console.warn('Failed to set workspace token in sessionStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the workspace token from sessionStorage
|
||||
*/
|
||||
clearWorkspaceToken(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
||||
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
} catch {
|
||||
console.warn('Failed to clear workspace token from sessionStorage')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch workspace and reload the page.
|
||||
* Clears the old workspace token before reload so fresh token is fetched.
|
||||
* Code after calling this won't execute (page is gone).
|
||||
*/
|
||||
switchWorkspaceAndReload(workspaceId: string): void {
|
||||
this.clearWorkspaceToken()
|
||||
this.setCurrentWorkspaceId(workspaceId)
|
||||
this.setLastWorkspaceId(workspaceId)
|
||||
window.location.reload()
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear workspace context and reload (e.g., after deletion).
|
||||
* Falls back to personal workspace on next boot.
|
||||
*/
|
||||
clearAndReload(): void {
|
||||
this.clearWorkspaceToken()
|
||||
this.clearCurrentWorkspaceId()
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
780
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
780
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
@@ -0,0 +1,780 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { TOKEN_REFRESH_BUFFER_MS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
|
||||
import { sessionManager } from '../services/sessionManager'
|
||||
import type {
|
||||
ExchangeTokenResponse,
|
||||
ListMembersParams,
|
||||
Member,
|
||||
PendingInvite as ApiPendingInvite,
|
||||
WorkspaceWithRole
|
||||
} from '../api/workspaceApi'
|
||||
import { workspaceApi, WorkspaceApiError } from '../api/workspaceApi'
|
||||
|
||||
// Extended member type for UI (adds joinDate as Date)
|
||||
export interface WorkspaceMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
joinDate: Date
|
||||
}
|
||||
|
||||
// Extended invite type for UI (adds dates as Date objects)
|
||||
export interface PendingInvite {
|
||||
id: string
|
||||
email: string
|
||||
token: string
|
||||
inviteDate: Date
|
||||
expiryDate: Date
|
||||
}
|
||||
|
||||
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||
|
||||
interface WorkspaceState extends WorkspaceWithRole {
|
||||
isSubscribed: boolean
|
||||
subscriptionPlan: SubscriptionPlan
|
||||
members: WorkspaceMember[]
|
||||
pendingInvites: PendingInvite[]
|
||||
}
|
||||
|
||||
type InitState = 'uninitialized' | 'loading' | 'ready' | 'error'
|
||||
|
||||
function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at)
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
|
||||
return {
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
token: invite.token,
|
||||
inviteDate: new Date(invite.invited_at),
|
||||
expiryDate: new Date(invite.expires_at)
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
|
||||
return {
|
||||
...workspace,
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace limits
|
||||
const MAX_OWNED_WORKSPACES = 10
|
||||
const MAX_WORKSPACE_MEMBERS = 50
|
||||
|
||||
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
const initState = ref<InitState>('uninitialized')
|
||||
const workspaces = shallowRef<WorkspaceState[]>([])
|
||||
const activeWorkspaceId = ref<string | null>(null)
|
||||
const error = ref<Error | null>(null)
|
||||
|
||||
// Loading states for UI
|
||||
const isCreating = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isSwitching = ref(false)
|
||||
const isFetchingWorkspaces = ref(false)
|
||||
|
||||
// Token refresh timer state
|
||||
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
|
||||
let tokenRefreshRequestId = 0
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// COMPUTED
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
const activeWorkspace = computed(
|
||||
() => workspaces.value.find((w) => w.id === activeWorkspaceId.value) ?? null
|
||||
)
|
||||
|
||||
const personalWorkspace = computed(
|
||||
() => workspaces.value.find((w) => w.type === 'personal') ?? null
|
||||
)
|
||||
|
||||
const isInPersonalWorkspace = computed(
|
||||
() => activeWorkspace.value?.type === 'personal'
|
||||
)
|
||||
|
||||
const sharedWorkspaces = computed(() =>
|
||||
workspaces.value.filter((w) => w.type !== 'personal')
|
||||
)
|
||||
|
||||
const ownedWorkspacesCount = computed(
|
||||
() => workspaces.value.filter((w) => w.role === 'owner').length
|
||||
)
|
||||
|
||||
const canCreateWorkspace = computed(
|
||||
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
|
||||
)
|
||||
|
||||
const members = computed<WorkspaceMember[]>(
|
||||
() => activeWorkspace.value?.members ?? []
|
||||
)
|
||||
|
||||
const pendingInvites = computed<PendingInvite[]>(
|
||||
() => activeWorkspace.value?.pendingInvites ?? []
|
||||
)
|
||||
|
||||
const totalMemberSlots = computed(
|
||||
() => members.value.length + pendingInvites.value.length
|
||||
)
|
||||
|
||||
const isInviteLimitReached = computed(
|
||||
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
|
||||
)
|
||||
|
||||
const workspaceId = computed(() => activeWorkspace.value?.id ?? null)
|
||||
|
||||
const workspaceName = computed(() => activeWorkspace.value?.name ?? '')
|
||||
|
||||
const isWorkspaceSubscribed = computed(
|
||||
() => activeWorkspace.value?.isSubscribed ?? false
|
||||
)
|
||||
|
||||
const subscriptionPlan = computed(
|
||||
() => activeWorkspace.value?.subscriptionPlan ?? null
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// INTERNAL HELPERS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
function updateWorkspace(
|
||||
workspaceId: string,
|
||||
updates: Partial<WorkspaceState>
|
||||
) {
|
||||
const index = workspaces.value.findIndex((w) => w.id === workspaceId)
|
||||
if (index === -1) return
|
||||
|
||||
const current = workspaces.value[index]
|
||||
const updated = { ...current, ...updates }
|
||||
workspaces.value = [
|
||||
...workspaces.value.slice(0, index),
|
||||
updated,
|
||||
...workspaces.value.slice(index + 1)
|
||||
]
|
||||
}
|
||||
|
||||
function updateActiveWorkspace(updates: Partial<WorkspaceState>) {
|
||||
if (!activeWorkspaceId.value) return
|
||||
updateWorkspace(activeWorkspaceId.value, updates)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// TOKEN MANAGEMENT
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
function stopRefreshTimer(): void {
|
||||
if (refreshTimerId !== null) {
|
||||
clearTimeout(refreshTimerId)
|
||||
refreshTimerId = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTokenRefresh(expiresAt: number): void {
|
||||
stopRefreshTimer()
|
||||
const now = Date.now()
|
||||
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
|
||||
const delay = Math.max(0, refreshAt - now)
|
||||
|
||||
refreshTimerId = setTimeout(() => {
|
||||
void refreshWorkspaceToken()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange Firebase token for workspace-scoped token.
|
||||
* Stores the token in sessionStorage and schedules refresh.
|
||||
*/
|
||||
async function exchangeAndStoreToken(
|
||||
workspaceId: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
const response = await workspaceApi.exchangeToken(workspaceId)
|
||||
const expiresAt = new Date(response.expires_at).getTime()
|
||||
|
||||
if (isNaN(expiresAt)) {
|
||||
throw new Error('Invalid token expiry timestamp from server')
|
||||
}
|
||||
|
||||
// Store token in sessionStorage
|
||||
sessionManager.setWorkspaceToken(response.token, expiresAt)
|
||||
|
||||
// Schedule refresh before expiry
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the workspace token.
|
||||
* Called automatically before token expires.
|
||||
* Includes retry logic for transient failures.
|
||||
*/
|
||||
async function refreshWorkspaceToken(): Promise<void> {
|
||||
if (!activeWorkspaceId.value) return
|
||||
|
||||
const workspaceId = activeWorkspaceId.value
|
||||
const capturedRequestId = tokenRefreshRequestId
|
||||
const maxRetries = 3
|
||||
const baseDelayMs = 1000
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
// Check if workspace context changed during refresh
|
||||
if (capturedRequestId !== tokenRefreshRequestId) {
|
||||
console.warn(
|
||||
'[workspaceStore] Aborting stale token refresh: workspace context changed'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await exchangeAndStoreToken(workspaceId)
|
||||
return
|
||||
} catch (err) {
|
||||
const isApiError = err instanceof WorkspaceApiError
|
||||
|
||||
// Permanent errors - don't retry
|
||||
const isPermanentError =
|
||||
isApiError &&
|
||||
(err.code === 'ACCESS_DENIED' ||
|
||||
err.code === 'WORKSPACE_NOT_FOUND' ||
|
||||
err.code === 'INVALID_FIREBASE_TOKEN' ||
|
||||
err.code === 'NOT_AUTHENTICATED')
|
||||
|
||||
if (isPermanentError) {
|
||||
if (capturedRequestId === tokenRefreshRequestId) {
|
||||
console.error(
|
||||
'[workspaceStore] Workspace access revoked or auth invalid:',
|
||||
err
|
||||
)
|
||||
clearTokenContext()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Transient errors - retry with backoff
|
||||
if (attempt < maxRetries) {
|
||||
const delay = baseDelayMs * Math.pow(2, attempt)
|
||||
console.warn(
|
||||
`[workspaceStore] Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
|
||||
err
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
if (capturedRequestId === tokenRefreshRequestId) {
|
||||
console.error(
|
||||
'[workspaceStore] Failed to refresh token after retries:',
|
||||
err
|
||||
)
|
||||
clearTokenContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear token context (on auth failure or workspace switch).
|
||||
*/
|
||||
function clearTokenContext(): void {
|
||||
tokenRefreshRequestId++
|
||||
stopRefreshTimer()
|
||||
sessionManager.clearWorkspaceToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a valid token in sessionStorage (for page refresh).
|
||||
* If valid, schedule refresh timer. If expired, return false.
|
||||
*/
|
||||
function initializeTokenFromSession(): boolean {
|
||||
const tokenData = sessionManager.getWorkspaceToken()
|
||||
if (!tokenData) return false
|
||||
|
||||
const { expiresAt } = tokenData
|
||||
if (Date.now() >= expiresAt) {
|
||||
// Token expired, clear it
|
||||
sessionManager.clearWorkspaceToken()
|
||||
return false
|
||||
}
|
||||
|
||||
// Token still valid, schedule refresh
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
return true
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// INITIALIZATION
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Initialize the workspace store.
|
||||
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
||||
* Call once on app boot.
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
if (initState.value !== 'uninitialized') return
|
||||
|
||||
initState.value = 'loading'
|
||||
isFetchingWorkspaces.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// 1. Fetch all workspaces
|
||||
const response = await workspaceApi.list()
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
|
||||
if (workspaces.value.length === 0) {
|
||||
throw new Error('No workspaces available')
|
||||
}
|
||||
|
||||
// 2. Determine active workspace (priority: sessionStorage > localStorage > personal)
|
||||
let targetWorkspaceId: string | null = null
|
||||
|
||||
// Try sessionStorage first (page refresh)
|
||||
const sessionId = sessionManager.getCurrentWorkspaceId()
|
||||
if (sessionId && workspaces.value.some((w) => w.id === sessionId)) {
|
||||
targetWorkspaceId = sessionId
|
||||
}
|
||||
|
||||
// Try localStorage (cross-session persistence)
|
||||
if (!targetWorkspaceId) {
|
||||
const lastId = sessionManager.getLastWorkspaceId()
|
||||
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
|
||||
targetWorkspaceId = lastId
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to personal workspace
|
||||
if (!targetWorkspaceId) {
|
||||
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||
}
|
||||
|
||||
// 3. Set active workspace
|
||||
activeWorkspaceId.value = targetWorkspaceId
|
||||
sessionManager.setCurrentWorkspaceId(targetWorkspaceId)
|
||||
sessionManager.setLastWorkspaceId(targetWorkspaceId)
|
||||
|
||||
// 4. Initialize workspace token
|
||||
// First check if we have a valid token from session (page refresh case)
|
||||
const hasValidToken = initializeTokenFromSession()
|
||||
|
||||
if (!hasValidToken) {
|
||||
// No valid token - exchange Firebase token for workspace token
|
||||
try {
|
||||
await exchangeAndStoreToken(targetWorkspaceId)
|
||||
} catch (tokenError) {
|
||||
// Log but don't fail initialization - API calls will fall back to Firebase token
|
||||
console.error(
|
||||
'[workspaceStore] Token exchange failed during init:',
|
||||
tokenError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
initState.value = 'ready'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error('Unknown error')
|
||||
initState.value = 'error'
|
||||
throw e
|
||||
} finally {
|
||||
isFetchingWorkspaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch workspaces from API without changing active workspace.
|
||||
*/
|
||||
async function refreshWorkspaces(): Promise<void> {
|
||||
isFetchingWorkspaces.value = true
|
||||
try {
|
||||
const response = await workspaceApi.list()
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
} finally {
|
||||
isFetchingWorkspaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// WORKSPACE ACTIONS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Switch to a different workspace.
|
||||
* Sets session storage and reloads the page.
|
||||
*/
|
||||
async function switchWorkspace(workspaceId: string): Promise<void> {
|
||||
if (workspaceId === activeWorkspaceId.value) return
|
||||
|
||||
// Invalidate any in-flight token refresh for the old workspace
|
||||
clearTokenContext()
|
||||
|
||||
isSwitching.value = true
|
||||
|
||||
try {
|
||||
// Verify workspace exists in our list (user has access)
|
||||
const workspace = workspaces.value.find((w) => w.id === workspaceId)
|
||||
if (!workspace) {
|
||||
// Workspace not in list - try refetching in case it was added
|
||||
await refreshWorkspaces()
|
||||
const refreshedWorkspace = workspaces.value.find(
|
||||
(w) => w.id === workspaceId
|
||||
)
|
||||
if (!refreshedWorkspace) {
|
||||
throw new Error('Workspace not found or access denied')
|
||||
}
|
||||
}
|
||||
|
||||
// Success - switch and reload
|
||||
sessionManager.switchWorkspaceAndReload(workspaceId)
|
||||
// Code after this won't run (page reloads)
|
||||
} catch (e) {
|
||||
isSwitching.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new workspace and switch to it.
|
||||
*/
|
||||
async function createWorkspace(name: string): Promise<WorkspaceState> {
|
||||
isCreating.value = true
|
||||
|
||||
try {
|
||||
const newWorkspace = await workspaceApi.create({ name })
|
||||
const workspaceState = createWorkspaceState(newWorkspace)
|
||||
|
||||
// Add to local list
|
||||
workspaces.value = [...workspaces.value, workspaceState]
|
||||
|
||||
// Switch to new workspace (triggers reload)
|
||||
sessionManager.switchWorkspaceAndReload(newWorkspace.id)
|
||||
|
||||
// Code after this won't run (page reloads)
|
||||
return workspaceState
|
||||
} catch (e) {
|
||||
isCreating.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a workspace.
|
||||
* If deleting active workspace, switches to personal.
|
||||
*/
|
||||
async function deleteWorkspace(workspaceId?: string): Promise<void> {
|
||||
const targetId = workspaceId ?? activeWorkspaceId.value
|
||||
if (!targetId) throw new Error('No workspace to delete')
|
||||
|
||||
const workspace = workspaces.value.find((w) => w.id === targetId)
|
||||
if (!workspace) throw new Error('Workspace not found')
|
||||
if (workspace.type === 'personal') {
|
||||
throw new Error('Cannot delete personal workspace')
|
||||
}
|
||||
|
||||
isDeleting.value = true
|
||||
|
||||
try {
|
||||
await workspaceApi.delete(targetId)
|
||||
|
||||
if (targetId === activeWorkspaceId.value) {
|
||||
// Deleted active workspace - go to personal
|
||||
const personal = personalWorkspace.value
|
||||
if (personal) {
|
||||
sessionManager.switchWorkspaceAndReload(personal.id)
|
||||
} else {
|
||||
sessionManager.clearAndReload()
|
||||
}
|
||||
// Code after this won't run (page reloads)
|
||||
} else {
|
||||
// Deleted non-active workspace - just update local list
|
||||
workspaces.value = workspaces.value.filter((w) => w.id !== targetId)
|
||||
isDeleting.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
isDeleting.value = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a workspace. No reload needed.
|
||||
*/
|
||||
async function renameWorkspace(
|
||||
workspaceId: string,
|
||||
newName: string
|
||||
): Promise<void> {
|
||||
const updated = await workspaceApi.update(workspaceId, { name: newName })
|
||||
updateWorkspace(workspaceId, { name: updated.name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workspace name (convenience for current workspace).
|
||||
*/
|
||||
async function updateWorkspaceName(name: string): Promise<void> {
|
||||
if (!activeWorkspaceId.value) {
|
||||
throw new Error('No active workspace')
|
||||
}
|
||||
await renameWorkspace(activeWorkspaceId.value, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the current workspace.
|
||||
* Switches to personal workspace after leaving.
|
||||
*/
|
||||
async function leaveWorkspace(): Promise<void> {
|
||||
const current = activeWorkspace.value
|
||||
if (!current || current.type === 'personal') {
|
||||
throw new Error('Cannot leave personal workspace')
|
||||
}
|
||||
|
||||
await workspaceApi.leave()
|
||||
|
||||
// Go to personal workspace
|
||||
const personal = personalWorkspace.value
|
||||
if (personal) {
|
||||
sessionManager.switchWorkspaceAndReload(personal.id)
|
||||
} else {
|
||||
sessionManager.clearAndReload()
|
||||
}
|
||||
// Code after this won't run (page reloads)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// MEMBER ACTIONS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Fetch members for the current workspace.
|
||||
*/
|
||||
async function fetchMembers(
|
||||
params?: ListMembersParams
|
||||
): Promise<WorkspaceMember[]> {
|
||||
if (!activeWorkspaceId.value) return []
|
||||
if (activeWorkspace.value?.type === 'personal') return []
|
||||
|
||||
const response = await workspaceApi.listMembers(params)
|
||||
const members = response.members.map(mapApiMemberToWorkspaceMember)
|
||||
updateActiveWorkspace({ members })
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the current workspace.
|
||||
*/
|
||||
async function removeMember(userId: string): Promise<void> {
|
||||
await workspaceApi.removeMember(userId)
|
||||
const current = activeWorkspace.value
|
||||
if (current) {
|
||||
updateActiveWorkspace({
|
||||
members: current.members.filter((m) => m.id !== userId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// INVITE ACTIONS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Fetch pending invites for the current workspace.
|
||||
*/
|
||||
async function fetchPendingInvites(): Promise<PendingInvite[]> {
|
||||
if (!activeWorkspaceId.value) return []
|
||||
if (activeWorkspace.value?.type === 'personal') return []
|
||||
|
||||
const response = await workspaceApi.listInvites()
|
||||
const invites = response.invites.map(mapApiInviteToPendingInvite)
|
||||
updateActiveWorkspace({ pendingInvites: invites })
|
||||
return invites
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invite for the current workspace.
|
||||
*/
|
||||
async function createInvite(email: string): Promise<PendingInvite> {
|
||||
const response = await workspaceApi.createInvite({ email })
|
||||
const invite = mapApiInviteToPendingInvite(response)
|
||||
|
||||
const current = activeWorkspace.value
|
||||
if (current) {
|
||||
updateActiveWorkspace({
|
||||
pendingInvites: [...current.pendingInvites, invite]
|
||||
})
|
||||
}
|
||||
|
||||
return invite
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending invite.
|
||||
*/
|
||||
async function revokeInvite(inviteId: string): Promise<void> {
|
||||
await workspaceApi.revokeInvite(inviteId)
|
||||
const current = activeWorkspace.value
|
||||
if (current) {
|
||||
updateActiveWorkspace({
|
||||
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a workspace invite.
|
||||
* Returns workspace info so UI can offer "View Workspace" button.
|
||||
*/
|
||||
async function acceptInvite(
|
||||
token: string
|
||||
): Promise<{ workspaceId: string; workspaceName: string }> {
|
||||
const response = await workspaceApi.acceptInvite(token)
|
||||
|
||||
// Refresh workspace list to include newly joined workspace
|
||||
await refreshWorkspaces()
|
||||
|
||||
return {
|
||||
workspaceId: response.workspace_id,
|
||||
workspaceName: response.workspace_name
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// INVITE LINK HELPERS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
const baseUrl = window.location.origin
|
||||
return `${baseUrl}?invite=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the invite link for a pending invite.
|
||||
*/
|
||||
function getInviteLink(inviteId: string): string | null {
|
||||
const invite = activeWorkspace.value?.pendingInvites.find(
|
||||
(i) => i.id === inviteId
|
||||
)
|
||||
return invite ? buildInviteLink(invite.token) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invite link for a given email.
|
||||
*/
|
||||
async function createInviteLink(email: string): Promise<string> {
|
||||
const invite = await createInvite(email)
|
||||
return buildInviteLink(invite.token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an invite link to clipboard.
|
||||
*/
|
||||
async function copyInviteLink(inviteId: string): Promise<string> {
|
||||
const invite = activeWorkspace.value?.pendingInvites.find(
|
||||
(i) => i.id === inviteId
|
||||
)
|
||||
if (!invite) {
|
||||
throw new Error('Invite not found')
|
||||
}
|
||||
const inviteLink = buildInviteLink(invite.token)
|
||||
await navigator.clipboard.writeText(inviteLink)
|
||||
return inviteLink
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// SUBSCRIPTION (placeholder for future integration)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
||||
console.warn(plan, 'Billing endpoint has not been added yet.')
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// CLEANUP
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Clean up store resources (timers, etc.).
|
||||
* Call when the store is no longer needed.
|
||||
*/
|
||||
function destroy(): void {
|
||||
clearTokenContext()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// RETURN
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
return {
|
||||
// State
|
||||
initState,
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
error,
|
||||
isCreating,
|
||||
isDeleting,
|
||||
isSwitching,
|
||||
isFetchingWorkspaces,
|
||||
|
||||
// Computed
|
||||
activeWorkspace,
|
||||
personalWorkspace,
|
||||
isInPersonalWorkspace,
|
||||
sharedWorkspaces,
|
||||
ownedWorkspacesCount,
|
||||
canCreateWorkspace,
|
||||
members,
|
||||
pendingInvites,
|
||||
totalMemberSlots,
|
||||
isInviteLimitReached,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan,
|
||||
|
||||
// Initialization & Cleanup
|
||||
initialize,
|
||||
destroy,
|
||||
refreshWorkspaces,
|
||||
|
||||
// Workspace Actions
|
||||
switchWorkspace,
|
||||
createWorkspace,
|
||||
deleteWorkspace,
|
||||
renameWorkspace,
|
||||
updateWorkspaceName,
|
||||
leaveWorkspace,
|
||||
|
||||
// Member Actions
|
||||
fetchMembers,
|
||||
removeMember,
|
||||
|
||||
// Invite Actions
|
||||
fetchPendingInvites,
|
||||
createInvite,
|
||||
revokeInvite,
|
||||
acceptInvite,
|
||||
getInviteLink,
|
||||
createInviteLink,
|
||||
copyInviteLink,
|
||||
|
||||
// Subscription
|
||||
subscribeWorkspace
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user