357 lines
19 KiB
Vue
357 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { Form, Head } from '@inertiajs/vue3';
|
|
import { Shield } from 'lucide-vue-next';
|
|
import TeamController from '@/actions/App/Http/Controllers/TeamController';
|
|
import Heading from '@/components/Heading.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import { index as teamsIndex } from '@/routes/teams';
|
|
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
|
|
|
|
const props = defineProps<TeamsIndexPageProps>();
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Teams',
|
|
href: teamsIndex(),
|
|
},
|
|
];
|
|
|
|
const hasPermission = (
|
|
permissions: Record<string, string[]>,
|
|
actorId: number,
|
|
permission: string,
|
|
) => (permissions[actorId] ?? []).includes(permission);
|
|
|
|
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
|
props.teamPermissions[teamId] ?? {};
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head title="Teams" />
|
|
|
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
|
<div
|
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<Heading
|
|
title="Teams"
|
|
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
|
|
/>
|
|
|
|
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
|
<Shield class="size-3.5" />
|
|
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div
|
|
v-if="status"
|
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
|
>
|
|
{{ status }}
|
|
</div>
|
|
|
|
<div
|
|
class="grid gap-6"
|
|
:class="
|
|
canCreateTeams
|
|
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
|
|
: 'xl:grid-cols-1'
|
|
"
|
|
>
|
|
<Card v-if="canCreateTeams">
|
|
<CardHeader>
|
|
<CardTitle>Create team</CardTitle>
|
|
<CardDescription>
|
|
Teams group users so project permissions can be
|
|
granted once and inherited by every member.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<Form
|
|
v-bind="TeamController.store.form()"
|
|
class="space-y-4"
|
|
v-slot="{ errors, processing }"
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label for="name">Team name</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
required
|
|
autofocus
|
|
placeholder="Platform Team"
|
|
/>
|
|
<InputError :message="errors.name" />
|
|
</div>
|
|
|
|
<Button type="submit" :disabled="processing">
|
|
Create team
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div class="grid gap-4">
|
|
<Card
|
|
v-for="team in teams"
|
|
:key="team.id"
|
|
class="gap-0 overflow-hidden"
|
|
>
|
|
<CardHeader class="border-b">
|
|
<div
|
|
class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"
|
|
>
|
|
<div>
|
|
<CardTitle>{{ team.name }}</CardTitle>
|
|
<CardDescription>
|
|
{{ team.users.length }} member{{
|
|
team.users.length === 1 ? '' : 's'
|
|
}}
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<Badge
|
|
v-for="member in team.users"
|
|
:key="member.id"
|
|
variant="secondary"
|
|
>
|
|
{{ member.name }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-6 pt-6">
|
|
<Form
|
|
v-bind="
|
|
TeamController.updateMembers.form(team.id)
|
|
"
|
|
class="space-y-4"
|
|
v-slot="{ processing }"
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label :for="`team-members-${team.id}`">
|
|
Members
|
|
</Label>
|
|
<select
|
|
:id="`team-members-${team.id}`"
|
|
name="user_ids[]"
|
|
multiple
|
|
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
|
>
|
|
<option
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
:value="user.id"
|
|
:selected="
|
|
team.user_ids.includes(user.id)
|
|
"
|
|
>
|
|
{{ user.name }} ({{ user.email }})
|
|
</option>
|
|
</select>
|
|
<p class="text-muted-foreground text-sm">
|
|
Hold Cmd or Ctrl to select multiple
|
|
users.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
:disabled="processing"
|
|
>
|
|
Save members
|
|
</Button>
|
|
</Form>
|
|
|
|
<div class="space-y-4 border-t pt-6">
|
|
<div class="space-y-1">
|
|
<h3 class="text-foreground font-medium">
|
|
Team permissions
|
|
</h3>
|
|
<p class="text-muted-foreground text-sm">
|
|
Direct user permissions on this team are
|
|
stored separately from team membership.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table
|
|
class="divide-border min-w-full divide-y text-sm"
|
|
>
|
|
<thead
|
|
class="bg-muted/40 text-muted-foreground text-left"
|
|
>
|
|
<tr>
|
|
<th
|
|
class="px-4 py-3 font-medium"
|
|
>
|
|
User
|
|
</th>
|
|
<th
|
|
class="px-4 py-3 font-medium"
|
|
>
|
|
Current permissions
|
|
</th>
|
|
<th
|
|
class="px-4 py-3 text-right font-medium"
|
|
>
|
|
Action
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-border divide-y">
|
|
<tr
|
|
v-for="user in users"
|
|
:key="`${team.id}-${user.id}`"
|
|
class="bg-background align-top"
|
|
>
|
|
<td class="px-4 py-4">
|
|
<div
|
|
class="text-foreground font-medium"
|
|
>
|
|
{{ user.name }}
|
|
</div>
|
|
<div
|
|
class="text-muted-foreground text-xs"
|
|
>
|
|
{{ user.email }}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-4">
|
|
<div
|
|
class="flex flex-wrap gap-2"
|
|
>
|
|
<Badge
|
|
v-for="option in permissionOptions"
|
|
v-show="
|
|
hasPermission(
|
|
permissionsForTeam(
|
|
team.id,
|
|
),
|
|
user.id,
|
|
option.value,
|
|
)
|
|
"
|
|
:key="`${team.id}-${user.id}-${option.value}-badge`"
|
|
variant="secondary"
|
|
>
|
|
{{ option.label }}
|
|
</Badge>
|
|
<span
|
|
v-if="
|
|
(
|
|
permissionsForTeam(
|
|
team.id,
|
|
)[
|
|
user.id
|
|
] ?? []
|
|
).length === 0
|
|
"
|
|
class="text-muted-foreground"
|
|
>
|
|
No direct team
|
|
permissions
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-4">
|
|
<div
|
|
class="flex justify-end"
|
|
>
|
|
<Form
|
|
v-bind="
|
|
TeamController.updateUserPermissions.form(
|
|
[
|
|
team.id,
|
|
user.id,
|
|
],
|
|
)
|
|
"
|
|
class="flex flex-wrap justify-end gap-3"
|
|
v-slot="{
|
|
processing,
|
|
}"
|
|
>
|
|
<label
|
|
v-for="option in permissionOptions"
|
|
:key="`${team.id}-${user.id}-${option.value}`"
|
|
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
name="permissions[]"
|
|
:value="
|
|
option.value
|
|
"
|
|
:checked="
|
|
hasPermission(
|
|
permissionsForTeam(
|
|
team.id,
|
|
),
|
|
user.id,
|
|
option.value,
|
|
)
|
|
"
|
|
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
/>
|
|
<span>
|
|
{{
|
|
option.label
|
|
}}
|
|
</span>
|
|
</label>
|
|
|
|
<div
|
|
class="w-full sm:hidden"
|
|
/>
|
|
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
variant="outline"
|
|
:disabled="
|
|
processing
|
|
"
|
|
>
|
|
Save
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-if="teams.length === 0">
|
|
<CardContent class="text-muted-foreground p-6 text-sm">
|
|
No teams are available for your current permissions.
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|