469 lines
19 KiB
Vue
469 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
|
import {
|
|
FolderPlus,
|
|
KeyRound,
|
|
Pencil,
|
|
ShieldCheck,
|
|
UserPlus,
|
|
UsersRound,
|
|
} from 'lucide-vue-next';
|
|
import { computed, ref } from 'vue';
|
|
import { update } from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
|
import FeedbackState from '@/components/FeedbackState.vue';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import {
|
|
create as createAdminUser,
|
|
edit as editAdminUser,
|
|
index as adminUsers,
|
|
} from '@/routes/admin/users';
|
|
import type {
|
|
AdminUsersPageProps,
|
|
BreadcrumbItem,
|
|
PermissionOption,
|
|
} from '@/types';
|
|
import type { UserModel } from '@/types/models/user-model';
|
|
|
|
const props = defineProps<AdminUsersPageProps>();
|
|
const { t } = useLocalization();
|
|
const permissionsOpen = ref(false);
|
|
const selectedUser = ref<UserModel | null>(null);
|
|
const permissionForm = useForm('AdminUserGlobalPermissions', {
|
|
permissions: [] as string[],
|
|
});
|
|
const permissionProtection = useFormProtection(permissionForm, {
|
|
isDirty: () => permissionsOpen.value && permissionForm.isDirty,
|
|
});
|
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|
{ title: t('adminUsers.title'), href: adminUsers() },
|
|
]);
|
|
const summaryItems = computed(() => [
|
|
{
|
|
label: t('adminUsers.summary.total'),
|
|
value: props.users.length,
|
|
icon: UsersRound,
|
|
},
|
|
{
|
|
label: t('adminUsers.summary.admins'),
|
|
value: props.users.filter((user) =>
|
|
hasPermission(user.id, 'global:admin'),
|
|
).length,
|
|
icon: ShieldCheck,
|
|
},
|
|
{
|
|
label: t('adminUsers.summary.passwordChanges'),
|
|
value: props.users.filter(
|
|
(user) => user.must_change_password_on_next_login,
|
|
).length,
|
|
icon: KeyRound,
|
|
},
|
|
]);
|
|
|
|
function permissionsFor(userId: number): string[] {
|
|
return props.directPermissions[userId] ?? [];
|
|
}
|
|
|
|
function hasPermission(userId: number, permission: string): boolean {
|
|
return permissionsFor(userId).includes(permission);
|
|
}
|
|
|
|
function permissionLabel(option: PermissionOption): string {
|
|
if (option.value === 'global:admin') {
|
|
return t('adminUsers.permissions.globalAdmin');
|
|
}
|
|
if (option.value === 'project:create') {
|
|
return t('adminUsers.permissions.projectCreate');
|
|
}
|
|
return option.label;
|
|
}
|
|
|
|
function openPermissions(user: UserModel): void {
|
|
selectedUser.value = user;
|
|
const permissions = [...permissionsFor(user.id)];
|
|
permissionForm.defaults({ permissions });
|
|
permissionForm.resetAndClearErrors();
|
|
permissionProtection.clearFeedback();
|
|
permissionsOpen.value = true;
|
|
}
|
|
|
|
function updatePermission(value: string, checked: boolean): void {
|
|
permissionForm.permissions = checked
|
|
? [...new Set([...permissionForm.permissions, value])]
|
|
: permissionForm.permissions.filter(
|
|
(permission) => permission !== value,
|
|
);
|
|
permissionForm.clearErrors('permissions');
|
|
permissionProtection.clearFeedback();
|
|
}
|
|
|
|
function closePermissions(): void {
|
|
permissionsOpen.value = false;
|
|
selectedUser.value = null;
|
|
permissionForm.defaults({ permissions: [] });
|
|
permissionForm.resetAndClearErrors();
|
|
permissionProtection.clearFeedback();
|
|
}
|
|
|
|
function updatePermissionsOpen(open: boolean): void {
|
|
if (open) {
|
|
permissionsOpen.value = true;
|
|
return;
|
|
}
|
|
|
|
if (!permissionForm.processing && permissionProtection.confirmLeave()) {
|
|
closePermissions();
|
|
}
|
|
}
|
|
|
|
function submitPermissions(): void {
|
|
if (!selectedUser.value) {
|
|
return;
|
|
}
|
|
|
|
permissionProtection.submit(update(selectedUser.value.id), {
|
|
preserveScroll: true,
|
|
onSuccess: closePermissions,
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
<Head :title="t('adminUsers.title')" />
|
|
|
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
|
<header
|
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="gap-stack grid min-w-0">
|
|
<p class="text-label text-muted-foreground uppercase">
|
|
{{ t('adminUsers.eyebrow') }}
|
|
</p>
|
|
<h1 class="text-headline-mobile md:text-headline">
|
|
{{ t('adminUsers.title') }}
|
|
</h1>
|
|
<p class="text-muted-foreground max-w-2xl">
|
|
{{ t('adminUsers.description') }}
|
|
</p>
|
|
</div>
|
|
<Button as-child class="self-start">
|
|
<Link :href="createAdminUser()">
|
|
<UserPlus aria-hidden="true" />
|
|
{{ t('adminUsers.createAction') }}
|
|
</Link>
|
|
</Button>
|
|
</header>
|
|
|
|
<FeedbackState
|
|
v-if="status"
|
|
state="success"
|
|
:title="t('adminUsers.updated')"
|
|
:description="status"
|
|
/>
|
|
|
|
<section :aria-label="t('adminUsers.summary.label')">
|
|
<ul class="grid gap-3 sm:grid-cols-3">
|
|
<li
|
|
v-for="item in summaryItems"
|
|
:key="item.label"
|
|
class="min-w-0"
|
|
>
|
|
<Card class="h-full gap-0 rounded-md py-0">
|
|
<CardContent
|
|
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
|
>
|
|
<div class="min-w-0">
|
|
<p
|
|
class="text-caption text-muted-foreground break-words"
|
|
>
|
|
{{ item.label }}
|
|
</p>
|
|
<p class="text-title mt-1 tabular-nums">
|
|
{{ item.value }}
|
|
</p>
|
|
</div>
|
|
<span
|
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
|
>
|
|
<component
|
|
:is="item.icon"
|
|
class="size-5"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section
|
|
aria-labelledby="managed-users-title"
|
|
class="gap-column grid min-w-0"
|
|
>
|
|
<div class="gap-stack grid">
|
|
<h2 id="managed-users-title" class="text-title">
|
|
{{
|
|
t('adminUsers.listTitle', {
|
|
count: users.length,
|
|
})
|
|
}}
|
|
</h2>
|
|
<p class="text-muted-foreground max-w-2xl">
|
|
{{ t('adminUsers.listDescription') }}
|
|
</p>
|
|
</div>
|
|
|
|
<FeedbackState
|
|
v-if="users.length === 0"
|
|
state="empty"
|
|
:title="t('adminUsers.empty.title')"
|
|
:description="t('adminUsers.empty.description')"
|
|
>
|
|
<Button as-child variant="outline">
|
|
<Link :href="createAdminUser()">
|
|
<UserPlus aria-hidden="true" />
|
|
{{ t('adminUsers.createAction') }}
|
|
</Link>
|
|
</Button>
|
|
</FeedbackState>
|
|
|
|
<ul
|
|
v-else
|
|
class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
|
|
>
|
|
<li
|
|
v-for="user in users"
|
|
:key="user.id"
|
|
class="min-w-0"
|
|
data-managed-user
|
|
>
|
|
<article
|
|
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
|
|
>
|
|
<div class="flex min-w-0 items-start gap-4 p-5">
|
|
<UserAvatar
|
|
:user="user"
|
|
size="lg"
|
|
rounded
|
|
class="ring-border shrink-0 ring-1"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<h3
|
|
class="text-body break-words font-semibold"
|
|
>
|
|
{{ user.name }}
|
|
</h3>
|
|
<p
|
|
class="text-caption text-muted-foreground break-all"
|
|
>
|
|
{{ user.email }}
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline" class="shrink-0">
|
|
{{ t('adminUsers.managedAccount') }}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div
|
|
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
|
|
>
|
|
<Badge
|
|
v-if="
|
|
user.must_change_password_on_next_login
|
|
"
|
|
variant="outline"
|
|
class="gap-1.5 whitespace-normal"
|
|
>
|
|
<KeyRound
|
|
class="size-3.5"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ t('adminUsers.passwordChangePending') }}
|
|
</Badge>
|
|
<Badge
|
|
v-for="option in permissionOptions"
|
|
v-show="
|
|
hasPermission(user.id, option.value)
|
|
"
|
|
:key="option.value"
|
|
variant="secondary"
|
|
class="gap-1.5 whitespace-normal"
|
|
>
|
|
<ShieldCheck
|
|
v-if="option.value === 'global:admin'"
|
|
class="size-3.5"
|
|
aria-hidden="true"
|
|
/>
|
|
<FolderPlus
|
|
v-else
|
|
class="size-3.5"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ permissionLabel(option) }}
|
|
</Badge>
|
|
<span
|
|
v-if="permissionsFor(user.id).length === 0"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('adminUsers.permissions.none') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
class="grid gap-2 p-4 sm:grid-cols-2"
|
|
data-user-actions
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
@click="openPermissions(user)"
|
|
>
|
|
<ShieldCheck aria-hidden="true" />
|
|
{{ t('adminUsers.permissions.action') }}
|
|
</Button>
|
|
<Button as-child variant="ghost">
|
|
<Link
|
|
:href="editAdminUser(user.id)"
|
|
:aria-label="
|
|
t('adminUsers.edit.action', {
|
|
name: user.name,
|
|
})
|
|
"
|
|
>
|
|
<Pencil aria-hidden="true" />
|
|
{{ t('adminUsers.edit.shortAction') }}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
|
|
<Dialog :open="permissionsOpen" @update:open="updatePermissionsOpen">
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{{
|
|
t('adminUsers.permissions.title', {
|
|
name: selectedUser?.name ?? '',
|
|
})
|
|
}}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{{ t('adminUsers.permissions.description') }}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form
|
|
class="gap-column grid"
|
|
:aria-busy="permissionForm.processing"
|
|
@submit.prevent="submitPermissions"
|
|
>
|
|
<FormFeedback
|
|
:state="permissionProtection.state.value"
|
|
@retry="submitPermissions"
|
|
/>
|
|
|
|
<fieldset
|
|
class="grid gap-3"
|
|
:disabled="permissionForm.processing"
|
|
>
|
|
<legend class="sr-only">
|
|
{{ t('adminUsers.permissions.available') }}
|
|
</legend>
|
|
<Label
|
|
v-for="option in permissionOptions"
|
|
:key="option.value"
|
|
:for="'permission-' + option.value"
|
|
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
|
>
|
|
<Checkbox
|
|
:id="'permission-' + option.value"
|
|
:checked="
|
|
permissionForm.permissions.includes(
|
|
option.value,
|
|
)
|
|
"
|
|
:value="option.value"
|
|
:invalid="!!permissionForm.errors.permissions"
|
|
:aria-describedby="
|
|
'permission-help-' + option.value
|
|
"
|
|
@update:checked="
|
|
(checked: boolean | 'indeterminate') =>
|
|
updatePermission(
|
|
option.value,
|
|
checked === true,
|
|
)
|
|
"
|
|
/>
|
|
<span class="grid gap-1">
|
|
<span>{{ permissionLabel(option) }}</span>
|
|
<span
|
|
:id="'permission-help-' + option.value"
|
|
class="text-caption text-muted-foreground font-normal"
|
|
>
|
|
{{
|
|
t(
|
|
option.value === 'global:admin'
|
|
? 'adminUsers.permissions.globalAdminHelp'
|
|
: 'adminUsers.permissions.projectCreateHelp',
|
|
)
|
|
}}
|
|
</span>
|
|
</span>
|
|
</Label>
|
|
<InputError
|
|
:message="permissionForm.errors.permissions"
|
|
/>
|
|
</fieldset>
|
|
|
|
<div
|
|
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
|
>
|
|
{{ t('adminUsers.permissions.warning') }}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
:disabled="permissionForm.processing"
|
|
@click="updatePermissionsOpen(false)"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
:loading="permissionForm.processing"
|
|
data-test="confirm-user-permissions"
|
|
>
|
|
{{ t('adminUsers.permissions.confirm') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AppLayout>
|
|
</template>
|