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:
2026-03-18 00:12:50 +00:00
parent 5dffd2d063
commit c89d1879bf
83 changed files with 5850 additions and 314 deletions

View File

@@ -35,8 +35,8 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import type { BreadcrumbItem, NavItem } from '@/types';
import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];

View File

@@ -9,8 +9,8 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>{{ $page.props.name }}</span
>
<span class="mb-0.5 truncate leading-tight font-semibold">{{
$page.props.name
}}</span>
</div>
</template>

View File

@@ -8,6 +8,7 @@ import {
HelpCircle,
LayoutGrid,
Users,
UsersRound,
} from 'lucide-vue-next';
import { computed } from 'vue';
import NavFooter from '@/components/NavFooter.vue';
@@ -23,11 +24,17 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as clientsIndex } from '@/routes/clients';
import { index as declarationsIndex } from '@/routes/declarations';
import { index as teamIndex } from '@/routes/team';
import type { NavItem } from '@/types';
import AppLogo from './AppLogo.vue';
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
const page = usePage();
const workspaceRole = computed(() => page.props.auth?.workspaceRole);
const isWorker = computed(() => workspaceRole.value === 'worker');
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
@@ -37,18 +44,31 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth?.currentWorkspace) {
items.push(
{
title: 'Clients',
href: '/clients',
icon: Briefcase,
},
{
title: 'Déclarations',
href: '/declarations',
if (isWorker.value) {
items.push({
title: 'Mes déclarations',
href: declarationsIndex.url(),
icon: FileStack,
},
);
});
} else {
items.push(
{
title: 'Clients',
href: clientsIndex.url(),
icon: Briefcase,
},
{
title: 'Déclarations',
href: declarationsIndex.url(),
icon: FileStack,
},
{
title: 'Équipe',
href: teamIndex.url(),
icon: UsersRound,
},
);
}
}
return items;
});

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -16,7 +17,6 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
const passwordInput = useTemplateRef('passwordInput');
</script>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { router } from '@inertiajs/vue3';
import {
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-vue-next';
interface PaginationData {
current_page: number;
@@ -33,7 +38,9 @@ const props = withDefaults(defineProps<Props>(), {
});
const canGoPrevious = computed(() => props.pagination.current_page > 1);
const canGoNext = computed(() => props.pagination.current_page < props.pagination.last_page);
const canGoNext = computed(
() => props.pagination.current_page < props.pagination.last_page,
);
const handlePerPageChange = (value: unknown): void => {
const str = value != null ? String(value) : null;
@@ -43,10 +50,14 @@ const handlePerPageChange = (value: unknown): void => {
url.searchParams.set('per_page', perPage.toString());
url.searchParams.set('page', '1');
router.get(url.pathname + url.search, {}, {
preserveState: true,
preserveScroll: true,
});
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
},
);
};
const goToPage = (page: number): void => {
@@ -57,27 +68,34 @@ const goToPage = (page: number): void => {
const url = new URL(window.location.href);
url.searchParams.set('page', page.toString());
router.get(url.pathname + url.search, {}, {
preserveState: true,
preserveScroll: true,
});
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
},
);
};
</script>
<template>
<div class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-muted-foreground text-sm">
<div
class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0">
{{ selectedCount }} sur {{ pagination.total }} ligne(s) sélectionnée(s).
</span>
<span v-else>
{{ pagination.total }} ligne(s) au total
{{ selectedCount }} sur {{ pagination.total }} ligne(s)
sélectionnée(s).
</span>
<span v-else> {{ pagination.total }} ligne(s) au total </span>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
<div class="flex items-center gap-2">
<span class="text-muted-foreground hidden text-sm sm:inline">Lignes par page</span>
<span class="hidden text-sm text-muted-foreground sm:inline"
>Lignes par page</span
>
<Select
:model-value="pagination.per_page.toString()"
@update:model-value="handlePerPageChange"
@@ -97,8 +115,9 @@ const goToPage = (page: number): void => {
</Select>
</div>
<div class="text-muted-foreground hidden text-sm md:inline">
Page {{ pagination.current_page }} sur {{ pagination.last_page }}
<div class="hidden text-sm text-muted-foreground md:inline">
Page {{ pagination.current_page }} sur
{{ pagination.last_page }}
</div>
<div class="flex items-center justify-center gap-2">
@@ -118,7 +137,7 @@ const goToPage = (page: number): void => {
>
<ChevronLeft class="size-4" />
</Button>
<div class="text-muted-foreground mx-2 text-sm md:hidden">
<div class="mx-2 text-sm text-muted-foreground md:hidden">
{{ pagination.current_page }}/{{ pagination.last_page }}
</div>
<Button

View File

@@ -21,8 +21,8 @@ import {
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import type { TwoFactorConfigContent } from '@/types';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
requiresConfirmation: boolean;

View File

@@ -71,7 +71,11 @@ const emit = defineEmits<{
type="password"
:required="passwordRequired"
autocomplete="new-password"
:placeholder="passwordRequired ? 'Password' : 'Leave blank to keep current'"
:placeholder="
passwordRequired
? 'Password'
: 'Leave blank to keep current'
"
aria-invalid="!!form.errors.password"
/>
<InputError :message="form.errors.password" />
@@ -97,7 +101,7 @@ const emit = defineEmits<{
id="group"
v-model="form.group"
required
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
class="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
:aria-invalid="!!form.errors.group"
>
<option value="" disabled>Select a group</option>

View File

@@ -8,9 +8,9 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import type { User } from '@/types';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
type Props = {
user: User;

View File

@@ -34,7 +34,7 @@ const emit = defineEmits<{
submit: [];
}>();
const defaultRole = 'member';
const defaultRole = 'worker';
function onUserToggle(userId: number, checked: boolean) {
if (checked) {
@@ -43,9 +43,14 @@ function onUserToggle(userId: number, checked: boolean) {
props.form.user_ids = [...ids, userId];
}
const roles = props.form.user_roles ?? {};
props.form.user_roles = { ...roles, [userId]: roles[userId] ?? defaultRole };
props.form.user_roles = {
...roles,
[userId]: roles[userId] ?? defaultRole,
};
} else {
props.form.user_ids = (props.form.user_ids ?? []).filter((id) => id !== userId);
props.form.user_ids = (props.form.user_ids ?? []).filter(
(id) => id !== userId,
);
const roles = { ...(props.form.user_roles ?? {}) };
delete roles[userId];
props.form.user_roles = roles;
@@ -113,10 +118,15 @@ function getUserRole(userId: number): string {
type="checkbox"
:value="user.id"
:checked="isUserSelected(user.id)"
class="border-input size-4 shrink-0 rounded-[4px] border focus-visible:ring-2 focus-visible:ring-ring"
@change="onUserToggle(user.id, ($event.target as HTMLInputElement).checked)"
class="size-4 shrink-0 rounded-[4px] border border-input focus-visible:ring-2 focus-visible:ring-ring"
@change="
onUserToggle(
user.id,
($event.target as HTMLInputElement).checked,
)
"
/>
<div class="min-w-0 flex-1 flex flex-col">
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-medium">{{ user.name }}</span>
<span class="text-xs text-muted-foreground">{{
user.email
@@ -125,7 +135,7 @@ function getUserRole(userId: number): string {
<select
:value="getUserRole(user.id)"
:disabled="!isUserSelected(user.id)"
class="border-input bg-background h-8 shrink-0 rounded-md border px-2 text-sm disabled:opacity-50"
class="h-8 shrink-0 rounded-md border border-input bg-background px-2 text-sm disabled:opacity-50"
@change="
onRoleChange(
user.id,

View File

@@ -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"
>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"