KAN-49: Rebuild user administration #142
@@ -0,0 +1,369 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { KeyRound, UserRound } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
save,
|
||||||
|
store,
|
||||||
|
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
user?: UserModel | null;
|
||||||
|
}>(),
|
||||||
|
{ user: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ cancel: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const isEdit = computed(() => props.mode === 'edit');
|
||||||
|
const confirmationOpen = ref(false);
|
||||||
|
const form = useForm(
|
||||||
|
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
|
||||||
|
{
|
||||||
|
name: props.user?.name ?? '',
|
||||||
|
email: props.user?.email ?? '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
must_change_password_on_next_login:
|
||||||
|
props.user?.must_change_password_on_next_login ?? true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
|
||||||
|
function performSubmit(): void {
|
||||||
|
confirmationOpen.value = false;
|
||||||
|
protection.submit(
|
||||||
|
isEdit.value && props.user ? save(props.user.id) : store(),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSubmit(): void {
|
||||||
|
if (isEdit.value) {
|
||||||
|
confirmationOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfirmation(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
|
confirmationOpen.value = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<UserRound class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<h2 class="text-title">
|
||||||
|
{{ t('adminUsers.form.detailsTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.editDescription'
|
||||||
|
: 'adminUsers.form.createDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-page grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="requestSubmit"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="performSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('adminUsers.form.detailsTitle') }}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-name">{{
|
||||||
|
t('adminUsers.form.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="managed-user-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="name"
|
||||||
|
:placeholder="
|
||||||
|
t('adminUsers.form.namePlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="managed-user-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-email">{{
|
||||||
|
t('adminUsers.form.email')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="managed-user-email"
|
||||||
|
v-model="form.email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
:placeholder="
|
||||||
|
t('adminUsers.form.emailPlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.email"
|
||||||
|
aria-describedby="managed-user-email-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-email-error"
|
||||||
|
:message="form.errors.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="managed-user-password-title"
|
||||||
|
class="gap-column grid min-w-0 border-t pt-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<KeyRound
|
||||||
|
class="text-muted-foreground mt-0.5 size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<h3
|
||||||
|
id="managed-user-password-title"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.passwordTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.passwordEditHelp'
|
||||||
|
: 'adminUsers.form.passwordCreateHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-password">{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.newPassword'
|
||||||
|
: 'adminUsers.form.password',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="managed-user-password"
|
||||||
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
|
:required="!isEdit"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.passwordEditPlaceholder'
|
||||||
|
: 'adminUsers.form.passwordPlaceholder',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.password"
|
||||||
|
aria-describedby="managed-user-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label
|
||||||
|
for="managed-user-password-confirmation"
|
||||||
|
>{{
|
||||||
|
t('adminUsers.form.confirmPassword')
|
||||||
|
}}</Label
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="managed-user-password-confirmation"
|
||||||
|
v-model="form.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
:required="!isEdit"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'adminUsers.form.confirmPasswordPlaceholder',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.password_confirmation
|
||||||
|
"
|
||||||
|
aria-describedby="managed-user-password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-password-confirmation-error"
|
||||||
|
:message="form.errors.password_confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-2 border-t pt-6">
|
||||||
|
<Label
|
||||||
|
for="managed-user-force-password-change"
|
||||||
|
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="managed-user-force-password-change"
|
||||||
|
v-model="
|
||||||
|
form.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
name="must_change_password_on_next_login"
|
||||||
|
:invalid="
|
||||||
|
!!form.errors
|
||||||
|
.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-1">
|
||||||
|
<span>{{
|
||||||
|
t('adminUsers.form.forcePasswordChange')
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
id="managed-user-force-password-change-help"
|
||||||
|
class="text-caption text-muted-foreground font-normal"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'adminUsers.form.forcePasswordChangeHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-force-password-change-error"
|
||||||
|
:message="
|
||||||
|
form.errors.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
class="sm:min-w-36"
|
||||||
|
data-test="managed-user-submit"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.reviewChanges'
|
||||||
|
: 'adminUsers.form.createAction',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('adminUsers.form.confirmTitle', {
|
||||||
|
name: form.name || props.user?.name || '',
|
||||||
|
})
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('adminUsers.form.confirmDescription') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.confirmWarning') }}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="confirmationOpen = false"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:loading="form.processing"
|
||||||
|
data-test="confirm-managed-user-update"
|
||||||
|
@click="performSubmit"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.confirmAction') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -154,6 +154,81 @@ export const en = {
|
|||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.done': 'Done',
|
'common.done': 'Done',
|
||||||
|
|
||||||
|
'adminUsers.eyebrow': 'Workspace administration',
|
||||||
|
'adminUsers.title': 'User management',
|
||||||
|
'adminUsers.description':
|
||||||
|
'Manage workspace accounts, password requirements, and the global permissions that apply across KanbanGit.',
|
||||||
|
'adminUsers.managedAccount': 'Managed account',
|
||||||
|
'adminUsers.createAction': 'Create user',
|
||||||
|
'adminUsers.updated': 'User management updated',
|
||||||
|
'adminUsers.summary.label': 'User management summary',
|
||||||
|
'adminUsers.summary.total': 'Workspace users',
|
||||||
|
'adminUsers.summary.admins': 'Global administrators',
|
||||||
|
'adminUsers.summary.passwordChanges': 'Password changes pending',
|
||||||
|
'adminUsers.listTitle': 'Users ({count})',
|
||||||
|
'adminUsers.listDescription':
|
||||||
|
'Review account status, edit profile details, or confirm global access changes.',
|
||||||
|
'adminUsers.empty.title': 'No users yet',
|
||||||
|
'adminUsers.empty.description':
|
||||||
|
'Create the first managed account for this workspace.',
|
||||||
|
'adminUsers.passwordChangePending': 'Password change required',
|
||||||
|
'adminUsers.permissions.none': 'No direct global permissions',
|
||||||
|
'adminUsers.permissions.action': 'Manage access',
|
||||||
|
'adminUsers.permissions.available': 'Available global permissions',
|
||||||
|
'adminUsers.permissions.globalAdmin': 'Global administrator',
|
||||||
|
'adminUsers.permissions.globalAdminHelp':
|
||||||
|
'Allows access to workspace-wide administration, including this user management area.',
|
||||||
|
'adminUsers.permissions.projectCreate': 'Create projects',
|
||||||
|
'adminUsers.permissions.projectCreateHelp':
|
||||||
|
'Allows the user to create new projects without granting global administration.',
|
||||||
|
'adminUsers.permissions.title': 'Global access for {name}',
|
||||||
|
'adminUsers.permissions.description':
|
||||||
|
'Select the direct permissions this account should receive.',
|
||||||
|
'adminUsers.permissions.warning':
|
||||||
|
'These changes take effect across the workspace after confirmation.',
|
||||||
|
'adminUsers.permissions.confirm': 'Confirm access changes',
|
||||||
|
'adminUsers.create.title': 'Create user',
|
||||||
|
'adminUsers.create.description':
|
||||||
|
'Add a workspace account with an initial password and a clear first-login requirement.',
|
||||||
|
'adminUsers.edit.breadcrumb': 'Edit {name}',
|
||||||
|
'adminUsers.edit.pageTitle': 'Edit {name}',
|
||||||
|
'adminUsers.edit.title': 'Edit {name}',
|
||||||
|
'adminUsers.edit.action': 'Edit account details for {name}',
|
||||||
|
'adminUsers.edit.shortAction': 'Edit details',
|
||||||
|
'adminUsers.form.detailsTitle': 'Account details',
|
||||||
|
'adminUsers.form.createDescription':
|
||||||
|
'Set the identity and initial security state for the new account.',
|
||||||
|
'adminUsers.form.editDescription':
|
||||||
|
'Update account details and review security-sensitive changes before saving.',
|
||||||
|
'adminUsers.form.name': 'Name',
|
||||||
|
'adminUsers.form.namePlaceholder': 'Jane Admin',
|
||||||
|
'adminUsers.form.email': 'Email address',
|
||||||
|
'adminUsers.form.emailPlaceholder': 'jane@example.com',
|
||||||
|
'adminUsers.form.passwordTitle': 'Password and first sign-in',
|
||||||
|
'adminUsers.form.passwordCreateHelp':
|
||||||
|
'Set a temporary password that the user can replace after signing in.',
|
||||||
|
'adminUsers.form.passwordEditHelp':
|
||||||
|
'Leave both password fields empty to keep the current password.',
|
||||||
|
'adminUsers.form.password': 'Temporary password',
|
||||||
|
'adminUsers.form.newPassword': 'New password',
|
||||||
|
'adminUsers.form.passwordPlaceholder': 'Enter a temporary password',
|
||||||
|
'adminUsers.form.passwordEditPlaceholder':
|
||||||
|
'Leave blank to keep the current password',
|
||||||
|
'adminUsers.form.confirmPassword': 'Confirm password',
|
||||||
|
'adminUsers.form.confirmPasswordPlaceholder': 'Repeat the new password',
|
||||||
|
'adminUsers.form.forcePasswordChange':
|
||||||
|
'Require a password change on next sign-in',
|
||||||
|
'adminUsers.form.forcePasswordChangeHelp':
|
||||||
|
'The user stays in security settings until a new password has been saved.',
|
||||||
|
'adminUsers.form.createAction': 'Create user',
|
||||||
|
'adminUsers.form.reviewChanges': 'Review changes',
|
||||||
|
'adminUsers.form.confirmTitle': 'Update {name}?',
|
||||||
|
'adminUsers.form.confirmDescription':
|
||||||
|
'Confirm the account and security changes before they are applied.',
|
||||||
|
'adminUsers.form.confirmWarning':
|
||||||
|
'A new password takes effect immediately. Requiring a password change restricts the user to security settings on their next sign-in.',
|
||||||
|
'adminUsers.form.confirmAction': 'Confirm and save',
|
||||||
|
|
||||||
'criteria.title': 'Acceptance criteria',
|
'criteria.title': 'Acceptance criteria',
|
||||||
'criteria.progress': '{completed} of {total} complete',
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
'criteria.empty': 'No acceptance criteria yet.',
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
|
|||||||
@@ -1,165 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index } from '@/routes/admin/users';
|
import { create, index } from '@/routes/admin/users';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'User management',
|
{ title: t('adminUsers.title'), href: index() },
|
||||||
href: index(),
|
{ title: t('adminUsers.create.title'), href: create() },
|
||||||
},
|
]);
|
||||||
{
|
|
||||||
title: 'Create user',
|
|
||||||
href: create(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Create user" />
|
<Head :title="t('adminUsers.create.title')" />
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||||
title="Create user"
|
>
|
||||||
description="Create a user account and optionally force a password change on the next login."
|
<header 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.create.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('adminUsers.create.description') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Card>
|
<ManagedUserForm mode="create" @cancel="router.visit(index())" />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Set an initial password that the user can replace after
|
|
||||||
their first sign-in.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="UserManagementController.store.form()"
|
|
||||||
:transform="
|
|
||||||
(data) => ({
|
|
||||||
...data,
|
|
||||||
must_change_password_on_next_login:
|
|
||||||
data.must_change_password_on_next_login ===
|
|
||||||
true ||
|
|
||||||
data.must_change_password_on_next_login ===
|
|
||||||
'on',
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="space-y-6"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
autocomplete="name"
|
|
||||||
placeholder="Jane Admin"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">Password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Temporary password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm password</Label
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
for="must_change_password_on_next_login"
|
|
||||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="must_change_password_on_next_login"
|
|
||||||
name="must_change_password_on_next_login"
|
|
||||||
type="checkbox"
|
|
||||||
checked
|
|
||||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span class="space-y-1">
|
|
||||||
<span class="block font-medium">
|
|
||||||
Must change password on next login
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-muted-foreground block text-sm"
|
|
||||||
>
|
|
||||||
Redirect the user to the security
|
|
||||||
settings page until they save a new
|
|
||||||
password.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.must_change_password_on_next_login
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Create user
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="index()">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,170 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { edit, index, save } from '@/routes/admin/users';
|
import { edit, index } from '@/routes/admin/users';
|
||||||
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<AdminUserEditPageProps>();
|
const props = defineProps<AdminUserEditPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('adminUsers.title'), href: index() },
|
||||||
{
|
{
|
||||||
title: 'User management',
|
title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
|
||||||
href: index(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Edit user',
|
|
||||||
href: edit(props.user.id),
|
href: edit(props.user.id),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
name: props.user.name,
|
|
||||||
email: props.user.email,
|
|
||||||
password: '',
|
|
||||||
password_confirmation: '',
|
|
||||||
must_change_password_on_next_login:
|
|
||||||
props.user.must_change_password_on_next_login,
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = (): void => {
|
|
||||||
form.patch(save.url(props.user.id), {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Edit ${user.name}`" />
|
<Head :title="t('adminUsers.edit.pageTitle', { name: user.name })" />
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||||
:title="`Edit ${user.name}`"
|
>
|
||||||
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
|
<header
|
||||||
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="user"
|
||||||
|
size="xl"
|
||||||
|
rounded
|
||||||
|
class="ring-border shrink-0 ring-1"
|
||||||
|
/>
|
||||||
|
<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 break-words"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.edit.title', { name: user.name }) }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground break-all">
|
||||||
|
{{ user.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ManagedUserForm
|
||||||
|
mode="edit"
|
||||||
|
:user="user"
|
||||||
|
@cancel="router.visit(index())"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Leave the password fields empty if the current password
|
|
||||||
should stay unchanged.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
v-model="form.name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
autocomplete="name"
|
|
||||||
placeholder="Jane Admin"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
v-model="form.email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">New password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
v-model="form.password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Leave blank to keep current password"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm new password</Label
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
v-model="form.password_confirmation"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.password_confirmation"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
for="must_change_password_on_next_login"
|
|
||||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="must_change_password_on_next_login"
|
|
||||||
v-model="
|
|
||||||
form.must_change_password_on_next_login
|
|
||||||
"
|
|
||||||
type="checkbox"
|
|
||||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span class="space-y-1">
|
|
||||||
<span class="block font-medium">
|
|
||||||
Must change password on next login
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-muted-foreground block text-sm"
|
|
||||||
>
|
|
||||||
Keep the user locked to the security
|
|
||||||
settings page until they save a new
|
|
||||||
password.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
form.errors
|
|
||||||
.must_change_password_on_next_login
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
|
||||||
Save user
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="index()">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,267 +1,468 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Pencil,
|
Pencil,
|
||||||
Shield,
|
ShieldCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
UsersRound,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
import { computed, ref } from 'vue';
|
||||||
import Heading from '@/components/Heading.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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import {
|
||||||
create as createAdminUser,
|
create as createAdminUser,
|
||||||
edit as editAdminUser,
|
edit as editAdminUser,
|
||||||
index as adminUsers,
|
index as adminUsers,
|
||||||
} from '@/routes/admin/users';
|
} from '@/routes/admin/users';
|
||||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
import type {
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
AdminUsersPageProps,
|
||||||
|
BreadcrumbItem,
|
||||||
|
PermissionOption,
|
||||||
|
} from '@/types';
|
||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
const props = defineProps<AdminUsersPageProps>();
|
const props = defineProps<AdminUsersPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
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(() => [
|
||||||
{
|
{
|
||||||
title: 'User management',
|
label: t('adminUsers.summary.total'),
|
||||||
href: adminUsers(),
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const hasPermission = (actorId: number, permission: string) =>
|
function permissionsFor(userId: number): string[] {
|
||||||
(props.directPermissions[actorId] ?? []).includes(permission);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="User management" />
|
<Head :title="t('adminUsers.title')" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<header
|
||||||
<Heading
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
title="User management"
|
>
|
||||||
description="Assign the global permissions that unlock platform-wide administration and project creation."
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<FeedbackState
|
||||||
<Button as-child>
|
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()">
|
<Link :href="createAdminUser()">
|
||||||
<UserPlus class="size-4" />
|
<UserPlus aria-hidden="true" />
|
||||||
User create
|
{{ t('adminUsers.createAction') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
<ul
|
||||||
<Users class="size-3.5" />
|
v-else
|
||||||
Admin-only area
|
class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
|
||||||
</Badge>
|
>
|
||||||
</div>
|
<li
|
||||||
</div>
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
<div
|
class="min-w-0"
|
||||||
v-if="status"
|
data-managed-user
|
||||||
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>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Users</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Global permissions are assigned directly to users and
|
|
||||||
apply across the entire application.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<div
|
|
||||||
v-if="users.length === 0"
|
|
||||||
class="text-muted-foreground p-6 text-sm"
|
|
||||||
>
|
>
|
||||||
No users exist yet.
|
<article
|
||||||
</div>
|
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
|
||||||
<table
|
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
|
||||||
>
|
>
|
||||||
<thead
|
<div class="flex min-w-0 items-start gap-4 p-5">
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
<UserAvatar
|
||||||
>
|
:user="user"
|
||||||
<tr>
|
size="lg"
|
||||||
<th class="px-4 py-3 font-medium">User</th>
|
rounded
|
||||||
<th class="px-4 py-3 font-medium">Email</th>
|
class="ring-border shrink-0 ring-1"
|
||||||
<th class="px-4 py-3 font-medium">
|
/>
|
||||||
Current access
|
<div class="min-w-0 flex-1">
|
||||||
</th>
|
<h3
|
||||||
<th
|
class="text-body break-words font-semibold"
|
||||||
class="px-4 py-3 text-right font-medium"
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
>
|
>
|
||||||
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="px-4 py-4">
|
|
||||||
<div
|
|
||||||
class="text-foreground items-medium flex items-center"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
:user="user"
|
|
||||||
class="mr-2 size-8"
|
|
||||||
rounded
|
|
||||||
/>
|
|
||||||
{{ user.name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{ user.email }}
|
{{ user.email }}
|
||||||
</td>
|
</p>
|
||||||
<td class="px-4 py-4">
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<Badge variant="outline" class="shrink-0">
|
||||||
<Badge
|
{{ t('adminUsers.managedAccount') }}
|
||||||
v-if="
|
</Badge>
|
||||||
user.must_change_password_on_next_login
|
</div>
|
||||||
"
|
|
||||||
variant="outline"
|
|
||||||
class="gap-1.5"
|
|
||||||
>
|
|
||||||
<KeyRound class="size-3.5" />
|
|
||||||
Must change password
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="
|
|
||||||
hasPermission(
|
|
||||||
user.id,
|
|
||||||
'global:admin',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="gap-1.5"
|
|
||||||
>
|
|
||||||
<Shield class="size-3.5" />
|
|
||||||
Global admin
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="
|
|
||||||
hasPermission(
|
|
||||||
user.id,
|
|
||||||
'project:create',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
variant="secondary"
|
|
||||||
class="gap-1.5"
|
|
||||||
>
|
|
||||||
<FolderPlus class="size-3.5" />
|
|
||||||
Project create
|
|
||||||
</Badge>
|
|
||||||
<span
|
|
||||||
v-if="
|
|
||||||
(
|
|
||||||
directPermissions[
|
|
||||||
user.id
|
|
||||||
] ?? []
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
No direct global permissions
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-end gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
as-child
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="
|
|
||||||
editAdminUser(
|
|
||||||
user.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Pencil
|
|
||||||
class="size-4"
|
|
||||||
/>
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Form
|
<div
|
||||||
v-bind="
|
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
|
||||||
UserManagementController.update.form(
|
>
|
||||||
user.id,
|
<Badge
|
||||||
)
|
v-if="
|
||||||
"
|
user.must_change_password_on_next_login
|
||||||
class="flex flex-wrap justify-end gap-3"
|
"
|
||||||
v-slot="{ processing }"
|
variant="outline"
|
||||||
>
|
class="gap-1.5 whitespace-normal"
|
||||||
<label
|
>
|
||||||
v-for="option in permissionOptions"
|
<KeyRound
|
||||||
:key="`${user.id}-${option.value}`"
|
class="size-3.5"
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<input
|
{{ t('adminUsers.passwordChangePending') }}
|
||||||
type="checkbox"
|
</Badge>
|
||||||
name="permissions[]"
|
<Badge
|
||||||
:value="
|
v-for="option in permissionOptions"
|
||||||
option.value
|
v-show="
|
||||||
"
|
hasPermission(user.id, option.value)
|
||||||
:checked="
|
"
|
||||||
hasPermission(
|
:key="option.value"
|
||||||
user.id,
|
variant="secondary"
|
||||||
option.value,
|
class="gap-1.5 whitespace-normal"
|
||||||
)
|
>
|
||||||
"
|
<ShieldCheck
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
v-if="option.value === 'global:admin'"
|
||||||
/>
|
class="size-3.5"
|
||||||
<span>{{
|
aria-hidden="true"
|
||||||
option.label
|
/>
|
||||||
}}</span>
|
<FolderPlus
|
||||||
</label>
|
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
|
<div
|
||||||
class="w-full sm:hidden"
|
class="grid gap-2 p-4 sm:grid-cols-2"
|
||||||
/>
|
data-user-actions
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
variant="outline"
|
||||||
size="sm"
|
@click="openPermissions(user)"
|
||||||
variant="outline"
|
>
|
||||||
:disabled="processing"
|
<ShieldCheck aria-hidden="true" />
|
||||||
>
|
{{ t('adminUsers.permissions.action') }}
|
||||||
Save
|
</Button>
|
||||||
</Button>
|
<Button as-child variant="ghost">
|
||||||
</Form>
|
<Link
|
||||||
</div>
|
:href="editAdminUser(user.id)"
|
||||||
</div>
|
:aria-label="
|
||||||
</td>
|
t('adminUsers.edit.action', {
|
||||||
</tr>
|
name: user.name,
|
||||||
</tbody>
|
})
|
||||||
</table>
|
"
|
||||||
</div>
|
>
|
||||||
</CardContent>
|
<Pencil aria-hidden="true" />
|
||||||
</Card>
|
{{ t('adminUsers.edit.shortAction') }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</div>
|
</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>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserManagementUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_user_management_pages_render_responsive_actions_and_protected_forms(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/user-management.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$process->isSuccessful(),
|
||||||
|
$process->getOutput().$process->getErrorOutput(),
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'User management rendering checks passed.',
|
||||||
|
$process->getOutput(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import { createAppearance } from '@/composables/useAppearance';
|
||||||
|
import { createLocalization } from '@/lib/localization';
|
||||||
|
import Create from '@/pages/admin/users/Create.vue';
|
||||||
|
import Edit from '@/pages/admin/users/Edit.vue';
|
||||||
|
import Index from '@/pages/admin/users/Index.vue';
|
||||||
|
import '@/../css/app.css';
|
||||||
|
|
||||||
|
const components = { Create, Edit, Index };
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) => components[name],
|
||||||
|
setup({ el, App, props, plugin }) {
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/user-management-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Admin',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
email_verified_at: null,
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
const member = {
|
||||||
|
...admin,
|
||||||
|
id: 2,
|
||||||
|
name: 'Morgan Member',
|
||||||
|
email: 'morgan@example.test',
|
||||||
|
must_change_password_on_next_login: true,
|
||||||
|
};
|
||||||
|
const shared = {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: admin,
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: true,
|
||||||
|
projectCreate: true,
|
||||||
|
canManageTeams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
};
|
||||||
|
const pages = {
|
||||||
|
index: {
|
||||||
|
component: 'Index',
|
||||||
|
url: '/admin/users',
|
||||||
|
props: {
|
||||||
|
...shared,
|
||||||
|
directPermissions: {
|
||||||
|
1: ['global:admin', 'project:create'],
|
||||||
|
2: [],
|
||||||
|
},
|
||||||
|
permissionOptions: [
|
||||||
|
{ value: 'global:admin', label: 'Global admin' },
|
||||||
|
{ value: 'project:create', label: 'Create project' },
|
||||||
|
],
|
||||||
|
users: [admin, member],
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
component: 'Create',
|
||||||
|
url: '/admin/users/create',
|
||||||
|
props: shared,
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
component: 'Edit',
|
||||||
|
url: '/admin/users/2/edit',
|
||||||
|
props: { ...shared, user: member },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function inertiaPage(page) {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(path, page) {
|
||||||
|
const component = (await server.ssrLoadModule(path)).default;
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
await createInertiaApp({
|
||||||
|
page: inertiaPage(page),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/admin/users')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = new URL(request.url, 'http://localhost').pathname;
|
||||||
|
const page = pathname.endsWith('/create')
|
||||||
|
? pages.create
|
||||||
|
: pathname.endsWith('/edit')
|
||||||
|
? pages.edit
|
||||||
|
: pages.index;
|
||||||
|
const completePage = inertiaPage(page);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(completePage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = JSON.stringify(completePage)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/user-management-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('User management fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const indexHtml = await render(
|
||||||
|
'/resources/js/pages/admin/users/Index.vue',
|
||||||
|
pages.index,
|
||||||
|
);
|
||||||
|
assert.match(indexHtml, /Workspace administration/);
|
||||||
|
assert.match(indexHtml, /User management/);
|
||||||
|
assert.match(indexHtml, /Workspace users/);
|
||||||
|
assert.match(indexHtml, /Global administrators/);
|
||||||
|
assert.match(indexHtml, /Password changes pending/);
|
||||||
|
assert.equal(indexHtml.match(/data-managed-user/g)?.length, 2);
|
||||||
|
assert.match(indexHtml, /Alex Admin/);
|
||||||
|
assert.match(indexHtml, /Morgan Member/);
|
||||||
|
assert.match(indexHtml, /Manage access/);
|
||||||
|
assert.match(indexHtml, /Edit account details for Morgan Member/);
|
||||||
|
assert.match(indexHtml, /Password change required/);
|
||||||
|
assert.match(indexHtml, /No direct global permissions/);
|
||||||
|
|
||||||
|
const createHtml = await render(
|
||||||
|
'/resources/js/pages/admin/users/Create.vue',
|
||||||
|
pages.create,
|
||||||
|
);
|
||||||
|
assert.match(createHtml, /Create user/);
|
||||||
|
assert.match(createHtml, /Temporary password/);
|
||||||
|
assert.match(createHtml, /Confirm password/);
|
||||||
|
assert.match(createHtml, /Require a password change on next sign-in/);
|
||||||
|
assert.match(createHtml, /data-test="managed-user-submit"/);
|
||||||
|
assert.match(createHtml, /managed-user-password-confirmation-error/);
|
||||||
|
|
||||||
|
const editHtml = await render(
|
||||||
|
'/resources/js/pages/admin/users/Edit.vue',
|
||||||
|
pages.edit,
|
||||||
|
);
|
||||||
|
assert.match(editHtml, /Edit Morgan Member/);
|
||||||
|
assert.match(editHtml, /morgan@example\.test/);
|
||||||
|
assert.match(editHtml, /Leave both password fields empty/);
|
||||||
|
assert.match(editHtml, /Review changes/);
|
||||||
|
assert.match(editHtml, /value="Morgan Member"/);
|
||||||
|
assert.match(editHtml, /value="morgan@example\.test"/);
|
||||||
|
|
||||||
|
console.log('User management rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user