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

@@ -89,75 +89,74 @@
}
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;

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"

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { CheckCircle, XCircle, X } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
@@ -12,6 +15,34 @@ type Props = {
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
const page = usePage<{
flash: { success?: string; error?: string };
}>();
const flashMessage = ref<{ type: 'success' | 'error'; text: string } | null>(
null,
);
const flash = computed(() => page.props.flash);
watch(
flash,
(val) => {
if (val?.success) {
flashMessage.value = { type: 'success', text: val.success };
setTimeout(() => {
flashMessage.value = null;
}, 4000);
} else if (val?.error) {
flashMessage.value = { type: 'error', text: val.error };
setTimeout(() => {
flashMessage.value = null;
}, 4000);
}
},
{ immediate: true },
);
</script>
<template>
@@ -21,5 +52,40 @@ withDefaults(defineProps<Props>(), {
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />
</AppContent>
<!-- Flash Messages -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<div
v-if="flashMessage"
class="fixed right-4 bottom-4 z-50 flex max-w-sm items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg"
:class="
flashMessage.type === 'success'
? 'border-green-200 dark:border-green-800'
: 'border-red-200 dark:border-red-800'
"
>
<CheckCircle
v-if="flashMessage.type === 'success'"
class="h-5 w-5 shrink-0 text-green-600"
/>
<XCircle v-else class="h-5 w-5 shrink-0 text-red-600" />
<span class="text-sm">{{ flashMessage.text }}</span>
<button
class="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
@click="flashMessage = null"
>
<X class="h-4 w-4" />
</button>
</div>
</Transition>
</Teleport>
</AppShell>
</template>

View File

@@ -5,11 +5,11 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils';
import type { NavItem } from '@/types';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editProfile } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [
{

View File

@@ -10,8 +10,8 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import type { TwoFactorConfigContent } from '@/types';
import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {

View File

@@ -3,8 +3,8 @@ import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientFormData } from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientContactData, ClientFormData } from '@/components/ClientForm.vue';
import type {
ClientContactData,
ClientFormData,
} from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -2,9 +2,9 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Client = {
id: number;
@@ -36,9 +36,12 @@ type Props = {
clients: PaginatedData<Client>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(client: Client) {
if (
@@ -73,11 +76,7 @@ function getLegalFormLabel(legalForm: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Clients' }]">
<Head title="Clients" />
<div class="flex flex-col space-y-6 p-4">
@@ -87,40 +86,42 @@ function getLegalFormLabel(legalForm: string): string {
title="Clients"
:description="`Gérer les clients du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Ajouter un client</Link>
</Button>
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Raison sociale
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Forme juridique
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
ICE
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -147,20 +148,33 @@ function getLegalFormLabel(legalForm: string): string {
{{ client.ice || '—' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ client.status ? statusLabels[client.status] ?? client.status : '—' }}
{{
client.status
? (statusLabels[client.status] ??
client.status)
: '—'
}}
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="client.showUrl"
>Voir</Link
>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="client.showUrl">Voir</Link>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
>
<Link :href="client.editUrl"
>Modifier</Link
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(client)"
@@ -174,10 +188,12 @@ function getLegalFormLabel(legalForm: string): string {
colspan="5"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>Aucun client pour le moment.</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Ajouter votre premier
client</Link

View File

@@ -37,9 +37,12 @@ type Props = {
declarations: PaginatedData<Declaration>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(declaration: Declaration) {
if (
@@ -86,7 +89,7 @@ const statusLabels: Record<string, string> = {
title="Déclarations"
:description="`Gérer les déclarations du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Nouvelle déclaration</Link>
</Button>
</div>
@@ -175,6 +178,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
@@ -184,6 +188,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(declaration)"
@@ -204,7 +209,7 @@ const statusLabels: Record<string, string> = {
<p>
Aucune déclaration pour le moment.
</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Créer votre première
déclaration</Link

View File

@@ -79,6 +79,8 @@ type Props = {
workspaceUsers: WorkspaceUser[];
mentionStoreUrl: string;
canMention: boolean;
canEdit: boolean;
canDelete: boolean;
};
const props = defineProps<Props>();
@@ -344,7 +346,7 @@ const declarationTimelineItems = computed(() => {
typeLabels[declaration.type] ?? declaration.type
"
/>
<Button variant="outline" as-child>
<Button v-if="props.canEdit" variant="outline" as-child>
<Link :href="editUrl">Modifier la déclaration</Link>
</Button>
</div>

View File

@@ -4,8 +4,8 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { edit } from '@/routes/appearance';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -7,9 +8,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import { edit } from '@/routes/user-password';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -9,10 +10,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types';
type Props = {
mustVerifyEmail: boolean;

View File

@@ -10,8 +10,8 @@ import { Button } from '@/components/ui/button';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { disable, enable, show } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';
type Props = {
requiresConfirmation?: boolean;

View File

@@ -0,0 +1,606 @@
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3';
import {
MoreHorizontal,
Shield,
UserCog,
UserMinus,
UserPlus,
Users,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { index as teamIndex } from '@/routes/team';
import type { BreadcrumbItem, TeamPageProps, TeamMember } from '@/types';
type Props = TeamPageProps;
const ROLE_OWNER = 'owner';
const ROLE_MANAGER = 'manager';
const props = defineProps<Props>();
const showInviteDialog = ref(false);
const form = useForm({
email: '',
role: 'worker',
});
function submitInvite() {
form.post(props.inviteUrl, {
onSuccess: () => {
showInviteDialog.value = false;
form.reset();
},
});
}
// ── Role Change Dialog ──
const showRoleDialog = ref(false);
const roleChangeMember = ref<TeamMember | null>(null);
const selectedRole = ref('');
const roleChangeProcessing = ref(false);
function openRoleDialog(member: TeamMember) {
roleChangeMember.value = member;
selectedRole.value = member.role;
showRoleDialog.value = true;
}
const isRoleUnchanged = computed(
() => selectedRole.value === roleChangeMember.value?.role,
);
function submitRoleChange() {
if (!roleChangeMember.value || isRoleUnchanged.value) return;
router.patch(
roleChangeMember.value.updateRoleUrl,
{ role: selectedRole.value },
{
onStart: () => {
roleChangeProcessing.value = true;
},
onFinish: () => {
roleChangeProcessing.value = false;
},
onSuccess: () => {
showRoleDialog.value = false;
roleChangeMember.value = null;
},
},
);
}
// ── Remove Member Dialog ──
const showRemoveDialog = ref(false);
const removeMember = ref<TeamMember | null>(null);
const removeProcessing = ref(false);
function openRemoveDialog(member: TeamMember) {
removeMember.value = member;
showRemoveDialog.value = true;
}
function submitRemove() {
if (!removeMember.value) return;
router.delete(removeMember.value.removeUrl, {
onStart: () => {
removeProcessing.value = true;
},
onFinish: () => {
removeProcessing.value = false;
},
onSuccess: () => {
showRemoveDialog.value = false;
removeMember.value = null;
},
});
}
// ── Permissions Dialog ──
const showPermissionsDialog = ref(false);
const permissionsMember = ref<TeamMember | null>(null);
const permissionsProcessing = ref(false);
function openPermissionsDialog(member: TeamMember) {
permissionsMember.value = member;
showPermissionsDialog.value = true;
}
function togglePermission(key: string, value: boolean) {
if (!permissionsMember.value?.permissionsUrl) return;
const updatedPermissions = {
...permissionsMember.value.permissions,
[key]: value,
};
permissionsProcessing.value = true;
router.put(
permissionsMember.value.permissionsUrl,
{ permissions: updatedPermissions },
{
preserveScroll: true,
onFinish: () => {
permissionsProcessing.value = false;
},
onSuccess: () => {
if (permissionsMember.value) {
permissionsMember.value = {
...permissionsMember.value,
permissions: updatedPermissions,
};
}
},
},
);
}
// Keep permissionsMember in sync when props update (e.g. Inertia partial reload)
watch(
() => props.members,
(members) => {
if (!permissionsMember.value || !showPermissionsDialog.value) return;
const updated = members.find(
(m) => m.workspace_user_id === permissionsMember.value!.workspace_user_id,
);
if (updated) {
permissionsMember.value = updated;
}
},
);
function canShowActions(member: TeamMember): boolean {
// No actions for current user's own row
if (member.id === props.authUserId) return false;
// No actions for Owner rows (managers cannot modify owners)
if (member.role === ROLE_OWNER) return false;
return true;
}
const allMembers = computed(() => {
const active = props.members.map((m) => ({
id: `member-${m.id}`,
name: m.name,
email: m.email,
role: m.role,
date: m.joined_at,
status: m.status,
}));
const pending = props.pendingInvitations.map((inv) => ({
id: `invite-${inv.id}`,
name: null as string | null,
email: inv.email,
role: inv.role,
date: inv.invited_at,
status: inv.status,
}));
return [...active, ...pending];
});
const isEmpty = computed(
() => props.members.length <= 1 && props.pendingInvitations.length === 0,
);
const roleLabels = computed<Record<string, string>>(() => ({
owner: 'Propriétaire',
...props.roles,
}));
const statusLabels: Record<string, string> = {
active: 'Actif',
pending: 'En attente',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function getMemberData(member: {
id: string;
status: string;
}): TeamMember | null {
if (member.status !== 'active') return null;
const userId = Number(member.id.replace('member-', ''));
return props.members.find((m) => m.id === userId) ?? null;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: dashboard().url },
{ title: 'Équipe', href: teamIndex().url },
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Équipe" />
<!-- Invite Dialog (shared across empty state and header) -->
<Dialog v-model:open="showInviteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
<DialogDescription>
Envoyez une invitation par email pour ajouter un membre
à votre équipe.
</DialogDescription>
</DialogHeader>
<form class="space-y-4" @submit.prevent="submitInvite">
<div class="space-y-2">
<Label for="email">Adresse email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="nom@exemple.com"
required
/>
<p
v-if="form.errors.email"
class="text-sm text-destructive"
>
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="role">Rôle</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors.role"
class="text-sm text-destructive"
>
{{ form.errors.role }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
Envoyer l'invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Role Change Dialog -->
<Dialog v-model:open="showRoleDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Changer le rôle</DialogTitle>
<DialogDescription>
Sélectionnez le nouveau rôle pour
{{ roleChangeMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label>Rôle</Label>
<Select v-model="selectedRole">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
:disabled="isRoleUnchanged || roleChangeProcessing"
@click="submitRoleChange"
>
Confirmer
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<!-- Permissions Dialog -->
<Dialog v-model:open="showPermissionsDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription>
Configurez les permissions de
{{ permissionsMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div
v-for="(label, key) in availablePermissions"
:key="key"
class="flex items-center justify-between rounded-lg border p-3"
>
<Label :for="`perm-${key}`" class="cursor-pointer">
{{ label }}
</Label>
<Switch
:id="`perm-${key}`"
:checked="
permissionsMember?.permissions?.[key] ?? false
"
:disabled="permissionsProcessing"
@update:checked="
(val: boolean) => togglePermission(key, val)
"
/>
</div>
</div>
</DialogContent>
</Dialog>
<!-- Remove Member Dialog -->
<Dialog v-model:open="showRemoveDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Retirer le membre</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir retirer
{{ removeMember?.name }} de l'espace de travail ? Cette
action est irréversible.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showRemoveDialog = false">
Annuler
</Button>
<Button
variant="destructive"
:disabled="removeProcessing"
@click="submitRemove"
>
Retirer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Équipe"
description="Gérer les membres de votre équipe"
/>
<Button v-if="canManageTeam" @click="showInviteDialog = true">
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Empty State -->
<div
v-if="isEmpty"
class="flex flex-col items-center justify-center rounded-xl border border-sidebar-border/70 px-4 py-16 text-center dark:border-sidebar-border"
>
<Users class="mb-4 h-12 w-12 text-muted-foreground" />
<h3 class="text-lg font-medium">Aucun membre</h3>
<p class="mt-1 text-sm text-muted-foreground">
Invitez votre premier membre d'équipe
</p>
<Button
v-if="canManageTeam"
class="mt-4"
@click="showInviteDialog = true"
>
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Team Table -->
<div
v-else
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Nom
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rôle
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rejoint le
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
v-if="canManageTeam"
class="h-10 w-12 px-4 text-left align-middle font-medium"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in allMembers"
:key="member.id"
class="border-b border-sidebar-border/50 last:border-0 hover:bg-muted/50"
>
<td class="px-4 py-3 font-medium">
{{ member.name ?? '' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ member.email }}
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{
roleLabels[member.role] ??
member.role
}}
</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ formatDate(member.date) }}
</td>
<td class="px-4 py-3">
<Badge
:variant="
member.status === 'active'
? 'default'
: 'outline'
"
>
{{ statusLabels[member.status] }}
</Badge>
</td>
<td v-if="canManageTeam" class="px-4 py-3">
<template
v-if="
getMemberData(member) &&
canShowActions(
getMemberData(member)!,
)
"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="
isOwner &&
getMemberData(member)!
.role ===
ROLE_MANAGER
"
@click="
openPermissionsDialog(
getMemberData(
member,
)!,
)
"
>
<Shield
class="mr-2 h-4 w-4"
/>
Gérer les permissions
</DropdownMenuItem>
<DropdownMenuItem
@click="
openRoleDialog(
getMemberData(
member,
)!,
)
"
>
<UserCog
class="mr-2 h-4 w-4"
/>
Changer le rôle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-destructive"
@click="
openRemoveDialog(
getMemberData(
member,
)!,
)
"
>
<UserMinus
class="mr-2 h-4 w-4"
/>
Retirer de l'espace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Users } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {
id: number;
@@ -51,11 +51,7 @@ function formatGroup(group: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Users' }]">
<Head title="Users" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function formatGroup(group: string): string {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Group
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -114,8 +112,12 @@ function formatGroup(group: string): string {
{{ formatGroup(user.group) }}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="user.editUrl">Edit</Link>
</Button>
<Button
@@ -132,7 +134,9 @@ function formatGroup(group: string): string {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Users class="h-10 w-10" />
<p>No users yet.</p>
<Button as-child>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Props = {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {
id: number;
@@ -51,11 +51,7 @@ function destroy(workspace: Workspace) {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Workspaces' }]">
<Head title="Workspaces" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function destroy(workspace: Workspace) {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Slug
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Users
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -125,13 +123,21 @@ function destroy(workspace: Workspace) {
}}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.showUrl"
>View</Link
>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.editUrl"
>Edit</Link
>
@@ -150,7 +156,9 @@ function destroy(workspace: Workspace) {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>No workspaces yet.</p>
<Button as-child>

View File

@@ -19,6 +19,8 @@ export type Auth = {
user: User | null;
workspaces?: Workspace[];
currentWorkspace?: Workspace | null;
workspaceRole?: 'owner' | 'manager' | 'worker' | null;
workspaceSwitchUrl?: string | null;
};
export type TwoFactorConfigContent = {

View File

@@ -1,3 +1,4 @@
export * from './auth';
export * from './navigation';
export * from './team';
export * from './ui';

View File

@@ -0,0 +1,32 @@
export type TeamMember = {
id: number;
name: string;
email: string;
role: string;
joined_at: string;
status: 'active';
workspace_user_id: number;
updateRoleUrl: string;
removeUrl: string;
permissions?: Record<string, boolean>;
permissionsUrl?: string;
};
export type TeamInvitation = {
id: number;
email: string;
role: string;
invited_at: string;
status: 'pending';
};
export type TeamPageProps = {
members: TeamMember[];
pendingInvitations: TeamInvitation[];
canManageTeam: boolean;
isOwner: boolean;
availablePermissions: Record<string, string>;
authUserId: number;
inviteUrl: string;
roles: Record<string, string>;
};

View File

@@ -0,0 +1,17 @@
<x-mail::message>
# Invitation à rejoindre {{ $workspaceName }}
Bonjour,
Vous êtes invité(e) à rejoindre le cabinet **{{ $workspaceName }}** en tant que **{{ $roleLabel }}**.
Cliquez sur le bouton ci-dessous pour créer votre compte et rejoindre l'équipe.
<x-mail::button :url="$registerUrl" color="primary">
Rejoindre l'équipe
</x-mail::button>
Ce lien est valide jusqu'au {{ $expiresAt }}.
Si vous n'avez pas demandé cette invitation, vous pouvez ignorer cet email.
</x-mail::message>