345 lines
15 KiB
Vue
345 lines
15 KiB
Vue
<script setup lang="ts">
|
|
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 { 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';
|
|
import { general } from '@/routes/projects/settings';
|
|
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
|
import type { ProjectAccessTarget } from '@/types/project-access';
|
|
|
|
const props = defineProps<ProjectAccessPageProps>();
|
|
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="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="grid min-w-0 gap-2">
|
|
<p
|
|
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
|
>
|
|
{{ project.key }}
|
|
</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 shrink-0 flex-wrap gap-2">
|
|
<Button
|
|
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
|
|
>
|
|
</div>
|
|
</header>
|
|
<FeedbackState
|
|
v-if="status"
|
|
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')"
|
|
>
|
|
<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>
|
|
<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"
|
|
>
|
|
<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"
|
|
>
|
|
{{ 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>
|
|
<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>
|