feat: complete Epic 1 — team management & permission system
- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration - Story 1.2: Team page with member list, invitation system with queued email - Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging - Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal) - Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping - Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching - 83 new/modified files, 182 tests passing with zero regressions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Building2, ChevronsUpDown, Plus } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { BoxSelect, Check, ChevronsUpDown } from 'lucide-vue-next';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
@@ -19,30 +19,58 @@ import {
|
||||
const page = usePage();
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const workspaces = page.props.auth?.workspaces ?? [];
|
||||
const currentWorkspace = page.props.auth?.currentWorkspace ?? null;
|
||||
const workspaces = computed(() => page.props.auth?.workspaces ?? []);
|
||||
const currentWorkspace = computed(
|
||||
() => page.props.auth?.currentWorkspace ?? null,
|
||||
);
|
||||
const workspaceSwitchUrl = computed(
|
||||
() => page.props.auth?.workspaceSwitchUrl ?? '',
|
||||
);
|
||||
const canSwitch = computed(() => workspaces.value.length > 1);
|
||||
|
||||
const isSwitching = ref(false);
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function switchWorkspace(workspace: { id: number }) {
|
||||
router.post('/workspace/switch', { workspace_id: workspace.id }, {
|
||||
preserveState: false,
|
||||
onSuccess: () => router.reload(),
|
||||
});
|
||||
if (isSwitching.value || workspace.id === currentWorkspace.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSwitching.value = true;
|
||||
router.post(
|
||||
workspaceSwitchUrl.value,
|
||||
{ workspace_id: workspace.id },
|
||||
{
|
||||
preserveState: false,
|
||||
onFinish: () => {
|
||||
isSwitching.value = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu v-if="workspaces.length > 0">
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<!-- Multi-workspace: show dropdown -->
|
||||
<DropdownMenu v-if="canSwitch">
|
||||
<DropdownMenuTrigger as-child :disabled="isSwitching">
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
<Building2 class="size-4" />
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="grid flex-1 text-left text-sm leading-tight"
|
||||
@@ -75,46 +103,57 @@ function switchWorkspace(workspace: { id: number }) {
|
||||
v-for="workspace in workspaces"
|
||||
:key="workspace.id"
|
||||
class="gap-2 p-2"
|
||||
:disabled="isSwitching"
|
||||
@click="switchWorkspace(workspace)"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-sm border"
|
||||
class="flex size-6 items-center justify-center rounded-sm border text-xs font-semibold"
|
||||
>
|
||||
<Building2 class="size-3.5 shrink-0" />
|
||||
{{ getInitial(workspace.name) }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-1 flex-col">
|
||||
<span>{{ workspace.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
workspace.slug
|
||||
}}</span>
|
||||
</div>
|
||||
<Check
|
||||
v-if="currentWorkspace?.id === workspace.id"
|
||||
class="ml-auto size-4 text-primary"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<!-- <DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<Link
|
||||
href="/workspaces"
|
||||
class="flex w-full cursor-pointer items-center gap-2 p-2"
|
||||
>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<span class="font-medium text-muted-foreground">
|
||||
Manage workspaces
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem> -->
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Single workspace: static display -->
|
||||
<SidebarMenuButton v-else size="lg">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
|
||||
>
|
||||
{{
|
||||
currentWorkspace
|
||||
? getInitial(currentWorkspace.name)
|
||||
: '?'
|
||||
}}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ currentWorkspace?.name ?? 'Select workspace' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentWorkspace"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ currentWorkspace.slug }}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarMenu v-else>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<div
|
||||
class="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg border border-dashed"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user