feat(KAN-48): rebuild project access and permission assignment
This commit is contained in:
@@ -1,308 +1,344 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
|
||||
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.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 { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects/index';
|
||||
import { general as generalSettings } from '@/routes/projects/settings';
|
||||
import { access, index, show } from '@/routes/projects';
|
||||
import { general } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||
|
||||
const props = defineProps<ProjectAccessPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
href: access(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
permissions: Record<string, string[]>,
|
||||
actorId: number,
|
||||
permission: string,
|
||||
) => (permissions[actorId] ?? []).includes(permission);
|
||||
const page = usePage();
|
||||
const { t } = useLocalization();
|
||||
const search = ref('');
|
||||
const target = ref<ProjectAccessTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('navigation.access'), href: access(props.project.id) },
|
||||
]);
|
||||
const matches = (value: string) =>
|
||||
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
|
||||
const visibleUsers = computed(() =>
|
||||
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
|
||||
);
|
||||
const visibleTeams = computed(() =>
|
||||
props.teams.filter((team) => matches(team.name)),
|
||||
);
|
||||
const inheritedTeams = (userId: number) =>
|
||||
props.teams.filter(
|
||||
(team) =>
|
||||
team.user_ids.includes(userId) &&
|
||||
(props.teamPermissions[team.id] ?? []).length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} access`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
<Head
|
||||
:title="t('projectAccess.pageTitle', { project: project.name })"
|
||||
/>
|
||||
<main
|
||||
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||
>
|
||||
<header
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p
|
||||
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} access`"
|
||||
description="Assign direct user permissions or inherited team permissions for this project."
|
||||
/>
|
||||
</p>
|
||||
<h1 class="text-headline-mobile sm:text-headline">
|
||||
{{ t('projectAccess.title') }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-muted-foreground max-w-2xl break-words text-sm"
|
||||
>
|
||||
{{
|
||||
t('projectAccess.description', {
|
||||
project: project.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
v-if="page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
><Link :href="general(project.id)">{{
|
||||
t('navigation.general')
|
||||
}}</Link></Button
|
||||
>
|
||||
<Button as-child variant="outline"
|
||||
><Link :href="show(project.id)"
|
||||
><ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||
t('projectAccess.back')
|
||||
}}</Link
|
||||
></Button
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
</header>
|
||||
<FeedbackState
|
||||
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"
|
||||
state="success"
|
||||
:title="t('projectAccess.saved')"
|
||||
/>
|
||||
<aside
|
||||
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
|
||||
:aria-label="t('projectAccess.howItWorks')"
|
||||
>
|
||||
{{ status }}
|
||||
<ShieldCheck
|
||||
class="text-primary mt-0.5 size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="grid gap-1">
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('projectAccess.howItWorks') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.additiveHelp') }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.limitsHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="relative sm:max-w-md">
|
||||
<Label for="access-search" class="sr-only">{{
|
||||
t('projectAccess.search')
|
||||
}}</Label
|
||||
><Search
|
||||
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||
aria-hidden="true"
|
||||
/><Input
|
||||
id="access-search"
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="pl-9"
|
||||
:placeholder="t('projectAccess.search')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>User permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Direct assignments apply only to the selected user
|
||||
inside this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<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">Email</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
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="user.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td
|
||||
class="text-foreground px-4 py-4 font-medium"
|
||||
>
|
||||
{{ user.name }}
|
||||
</td>
|
||||
<td class="text-muted-foreground px-4 py-4">
|
||||
{{ user.email }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectController.updateUserPermissions.form(
|
||||
[project.id, user.id],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${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(
|
||||
directPermissions,
|
||||
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>
|
||||
</td>
|
||||
<td
|
||||
class="text-muted-foreground px-4 py-4 text-right"
|
||||
>
|
||||
Direct
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Team permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Team assignments are inherited by every current and
|
||||
future member of that team.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div
|
||||
v-if="teams.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
<section
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="people-heading"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 id="people-heading" class="text-lg font-semibold">
|
||||
{{ t('projectAccess.people') }}
|
||||
</h2>
|
||||
<Badge variant="secondary">{{ users.length }}</Badge>
|
||||
</div>
|
||||
<FeedbackState
|
||||
v-if="visibleUsers.length === 0"
|
||||
state="empty"
|
||||
:title="
|
||||
t(
|
||||
users.length
|
||||
? 'projectAccess.noPeopleResults'
|
||||
: 'projectAccess.noPeople',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="bg-card min-w-0 divide-y rounded-xl border"
|
||||
:aria-label="t('projectAccess.people')"
|
||||
>
|
||||
<li
|
||||
v-for="user in visibleUsers"
|
||||
:key="user.id"
|
||||
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
|
||||
:data-user-id="user.id"
|
||||
>
|
||||
No teams exist yet. Create teams first to assign
|
||||
inherited permissions.
|
||||
</div>
|
||||
|
||||
<div v-else 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">Team</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Members
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
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="team in teams"
|
||||
:key="team.id"
|
||||
class="bg-background align-top"
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<UserAvatar :user="user" class="size-10 shrink-0" />
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ user.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-muted-foreground break-all text-xs"
|
||||
>
|
||||
<td
|
||||
class="text-foreground px-4 py-4 font-medium"
|
||||
>
|
||||
{{ team.name }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="member in team.users"
|
||||
:key="member.id"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ member.name }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="team.users.length === 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No members assigned
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectController.updateTeamPermissions.form(
|
||||
[project.id, team.id],
|
||||
)
|
||||
"
|
||||
class="flex flex-wrap gap-3"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<label
|
||||
v-for="option in permissionOptions"
|
||||
:key="`${team.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(
|
||||
teamPermissions,
|
||||
team.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>
|
||||
</td>
|
||||
<td
|
||||
class="text-muted-foreground px-4 py-4 text-right"
|
||||
>
|
||||
Inherited
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<p
|
||||
class="text-muted-foreground text-xs font-medium"
|
||||
>
|
||||
{{ t('projectAccess.effective') }}
|
||||
</p>
|
||||
<ProjectPermissionBadges
|
||||
:permissions="
|
||||
effectivePermissions[user.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.noAccess')"
|
||||
/>
|
||||
<div
|
||||
class="text-muted-foreground grid gap-1 text-xs"
|
||||
>
|
||||
<span
|
||||
v-if="globalAdminUserIds.includes(user.id)"
|
||||
>{{ t('projectAccess.globalSource') }}</span
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span>{{ t('projectAccess.direct') }}</span
|
||||
><ProjectPermissionBadges
|
||||
:permissions="
|
||||
directPermissions[user.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.none')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="team in inheritedTeams(user.id)"
|
||||
:key="team.id"
|
||||
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="break-words">{{
|
||||
t('projectAccess.viaTeam', {
|
||||
name: team.name,
|
||||
})
|
||||
}}</span
|
||||
><ProjectPermissionBadges
|
||||
:permissions="
|
||||
teamPermissions[team.id] ?? []
|
||||
"
|
||||
:empty-label="t('projectAccess.none')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('projectAccess.editFor', { name: user.name })
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'user',
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
permissions:
|
||||
directPermissions[user.id] ?? [],
|
||||
globalAdmin: globalAdminUserIds.includes(
|
||||
user.id,
|
||||
),
|
||||
}
|
||||
"
|
||||
>{{ t('projectAccess.editDirect') }}</Button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
|
||||
<div class="grid gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 id="teams-heading" class="text-lg font-semibold">
|
||||
{{ t('projectAccess.teamPermissions') }}
|
||||
</h2>
|
||||
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('projectAccess.teamHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<FeedbackState
|
||||
v-if="visibleTeams.length === 0"
|
||||
state="empty"
|
||||
:title="
|
||||
t(
|
||||
teams.length
|
||||
? 'projectAccess.noTeamResults'
|
||||
: 'projectAccess.noTeams',
|
||||
)
|
||||
"
|
||||
:description="
|
||||
teams.length
|
||||
? undefined
|
||||
: t('projectAccess.noTeamsHelp')
|
||||
"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||
:aria-label="t('projectAccess.teamPermissions')"
|
||||
>
|
||||
<li
|
||||
v-for="team in visibleTeams"
|
||||
:key="team.id"
|
||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
|
||||
:data-team-id="team.id"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
><Users class="size-5" aria-hidden="true"
|
||||
/></span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="break-words text-sm font-semibold">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
t('teams.memberCount', {
|
||||
count: team.users.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectPermissionBadges
|
||||
:permissions="teamPermissions[team.id] ?? []"
|
||||
:empty-label="t('projectAccess.noTeamAccess')"
|
||||
/>
|
||||
<p class="text-muted-foreground break-words text-xs">
|
||||
{{
|
||||
team.users
|
||||
.map((user) => user.name)
|
||||
.join(', ') || t('teams.noMembers')
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('projectAccess.editFor', { name: team.name })
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'team',
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
permissions: teamPermissions[team.id] ?? [],
|
||||
}
|
||||
"
|
||||
>{{ t('projectAccess.editTeam') }}</Button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<ProjectAccessEditorDialog
|
||||
:project-id="project.id"
|
||||
:target="target"
|
||||
:permission-options="permissionOptions"
|
||||
@close="target = null"
|
||||
/>
|
||||
</main>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user