first commit

This commit is contained in:
2026-10-09 08:21:06 +02:00
parent db7456df87
commit d259c62304
3761 changed files with 896046 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue';
import { resolveWidgetComponent } from '@/components/widgets';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
import type { WidgetPayload } from '@/types';
const props = defineProps<{ widgets: WidgetPayload[] }>();
const { __ } = useTranslations();
/**
* Bootstrap's col-md-{n} becomes a span on a 12 column grid. The classes are
* written out in full so Tailwind can see them when scanning source.
*/
const WIDTHS: Record<string, string> = {
'1': 'md:col-span-1',
'2': 'md:col-span-2',
'3': 'md:col-span-3',
'4': 'md:col-span-4',
'5': 'md:col-span-5',
'6': 'md:col-span-6',
'7': 'md:col-span-7',
'8': 'md:col-span-8',
'9': 'md:col-span-9',
'10': 'md:col-span-10',
'11': 'md:col-span-11',
'12': 'md:col-span-12',
};
const resolved = computed(() =>
props.widgets
.map((widget, index) => ({
key: `${widget.component}-${index}`,
component: resolveWidgetComponent(widget.component),
props: widget.props,
widthClass: widget.width ? (WIDTHS[widget.width] ?? 'md:col-span-12') : 'md:col-span-12',
}))
.filter((widget) => widget.component !== undefined),
);
</script>
<template>
<AppLayout :title="__('Dashboard')" :heading="__('Dashboard')" :breadcrumbs="[{ label: __('Dashboard') }]">
<div class="grid grid-cols-1 gap-4 md:grid-cols-12">
<div v-for="widget in resolved" :key="widget.key" :class="widget.widthClass">
<component :is="widget.component" v-bind="widget.props" />
</div>
</div>
</AppLayout>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import AvatarCard from '@/components/users/AvatarCard.vue';
import LoginDetailsForm from '@/components/users/LoginDetailsForm.vue';
import TwoFactorPanel from '@/components/users/TwoFactorPanel.vue';
import UserDetailsForm from '@/components/users/UserDetailsForm.vue';
import Message from '@/components/ui/Message.vue';
import type { SelectOption } from '@/components/ui/Select.vue';
import Tabs from '@/components/ui/Tabs.vue';
import { useSettings, useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
import type {
AvatarSource,
LoginDetailsFormData,
TwoFactorState,
UserDetailsFormData,
UserFormPayload,
} from '@/types';
const props = defineProps<{
user: UserFormPayload;
roles: SelectOption[];
statuses: SelectOption[];
countries: SelectOption[];
avatarSources: AvatarSource[];
twoFactor: TwoFactorState;
forcePasswordChange: boolean;
activeTab: string;
}>();
const { __ } = useTranslations();
const { setting } = useSettings();
const details = useForm<UserDetailsFormData>({
role_id: props.user.role_id,
status: props.user.status,
first_name: props.user.first_name,
last_name: props.user.last_name,
birthday: props.user.birthday,
phone: props.user.phone,
address: props.user.address,
country_id: props.user.country_id,
});
const login = useForm<LoginDetailsFormData>({
email: props.user.email,
username: props.user.username,
password: '',
password_confirmation: '',
});
const tabs = computed(() => {
const items = [
{ value: 'details', label: __('User Details') },
{ value: 'login', label: __('Login Details') },
];
if (setting('2fa.enabled')) {
items.push({ value: '2fa', label: __('Two-Factor Authentication') });
}
return items;
});
const tab = ref(props.activeTab);
</script>
<template>
<AppLayout
:title="__('My Profile')"
:heading="__('My Profile')"
:breadcrumbs="[{ label: __('My Profile') }]"
>
<Message v-if="forcePasswordChange" severity="warn" class="mb-4">
{{ __('Before continuing please update your password and set a new one.') }}
</Message>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<Tabs v-model="tab" :tabs="tabs">
<template #details>
<form
@submit.prevent="
details.put(route('profile.update.details'), {
preserveScroll: true,
})
"
>
<!--
Role and status are read only here, matching the
disabled selects the Blade profile rendered.
-->
<UserDetailsForm
v-model="details"
locked
:roles="roles"
:statuses="statuses"
:countries="countries"
:errors="details.errors"
:submit-label="__('Update Details')"
:processing="details.processing"
/>
</form>
</template>
<template #login>
<form
@submit.prevent="
login.put(route('profile.update.login-details'), {
preserveScroll: true,
})
"
>
<LoginDetailsForm
v-model="login"
edit
:errors="login.errors"
:submit-label="__('Update Details')"
:processing="login.processing"
/>
</form>
</template>
<template #2fa>
<TwoFactorPanel :user-id="user.id" :two-factor="twoFactor" />
</template>
</Tabs>
</div>
</div>
<AvatarCard
:avatar="user.avatar"
:name-or-email="user.nameOrEmail"
:sources="avatarSources"
:upload-url="route('profile.update.avatar')"
:external-url="route('profile.update.avatar-external')"
/>
</div>
</AppLayout>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
const { __ } = useTranslations();
</script>
<template>
<AppLayout :title="__('Waiting Approval')" :heading="__('Waiting Approval')">
<div class="mx-auto max-w-2xl">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mb-4 text-lg font-medium">{{ __('Waiting Approval') }}</h5>
<p class="text-slate-600">
{{ __('Your account is waiting approval from administrators.') }}
</p>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import { useTranslations } from '@/composables/useVanguard';
import AuthLayout from '@/layouts/AuthLayout.vue';
const { __ } = useTranslations();
const form = useForm({ email: '' });
function submit() {
form.post(route('password.email'));
}
</script>
<template>
<AuthLayout :title="__('Reset Password')">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mt-2 mb-2 text-center text-sm font-semibold tracking-wider text-slate-600 uppercase">
{{ __('Forgot Your Password?') }}
</h5>
<form class="space-y-4 p-2" autocomplete="off" @submit.prevent="submit">
<p class="mb-4 text-center font-light text-slate-500">
{{ __('Please provide your email below and we will send you a password reset link.') }}
</p>
<div>
<label for="email" class="sr-only">{{ __('Email') }}</label>
<Input
id="email"
v-model="form.email"
type="email"
class="w-full"
:placeholder="__('Your E-Mail')"
:invalid="Boolean(form.errors.email)"
autocomplete="email"
/>
<small v-if="form.errors.email" class="text-red-600">{{ form.errors.email }}</small>
</div>
<Button
type="submit"
:label="__('Reset Password')"
class="w-full"
size="large"
:loading="form.processing"
/>
</form>
</div>
</div>
</AuthLayout>
</template>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { Link, useForm } from "@inertiajs/vue3";
import Button from "@/components/ui/Button.vue";
import Checkbox from "@/components/ui/Checkbox.vue";
import Input from "@/components/ui/Input.vue";
import Message from "@/components/ui/Message.vue";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import SocialButtons from "@/components/SocialButtons.vue";
import { useSettings, useTranslations } from "@/composables/useVanguard";
import AuthLayout from "@/layouts/AuthLayout.vue";
const props = defineProps<{
socialProviders: string[] | null;
to?: string | null;
}>();
const { __ } = useTranslations();
const { setting } = useSettings();
const form = useForm({
username: "",
password: "",
remember: false,
to: props.to ?? null,
});
function submit() {
form.post("/login", {
onFinish: () => form.reset("password"),
});
}
</script>
<template>
<AuthLayout :title="__('Login')">
<!-- Logo + Titolo principale (stile vecchia Blade) -->
<div class="text-center mb-6">
<img
src="/assets/img/cimac-logo.png"
:alt="setting('app_name')"
class="mx-auto h-12"
/>
<h3 class="mt-4 text-xl font-semibold tracking-wide text-slate-800">
CIMAC APPLICATION FORM
</h3>
</div>
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-10">
<h5
class="mt-2 mb-4 text-center text-sm font-semibold tracking-wider text-slate-600 uppercase"
>
{{ __("Login") }}
</h5>
<Message
v-if="form.errors.username"
severity="error"
class="mb-4"
>
{{ form.errors.username }}
</Message>
<form
class="space-y-4"
autocomplete="off"
@submit.prevent="submit"
>
<div>
<label for="username" class="sr-only">{{
__("Email or Username")
}}</label>
<Input
id="username"
v-model="form.username"
class="w-full"
:placeholder="__('Email or Username')"
:invalid="Boolean(form.errors.username)"
autocomplete="username"
/>
</div>
<div>
<label for="password" class="sr-only">{{
__("Password")
}}</label>
<PasswordInput
id="password"
v-model="form.password"
class="w-full"
:placeholder="__('Password')"
:invalid="Boolean(form.errors.password)"
autocomplete="current-password"
/>
<small v-if="form.errors.password" class="text-red-600">
{{ form.errors.password }}
</small>
</div>
<div
v-if="setting('remember_me')"
class="flex items-center gap-2"
>
<Checkbox v-model="form.remember" id="remember" />
<label for="remember" class="text-slate-600">
{{ __("Remember me?") }} Ricordami
</label>
</div>
<Button
type="submit"
:label="__('Log In')"
class="w-full"
size="large"
:loading="form.processing"
/>
</form>
<Link
v-if="setting('forgot_password')"
:href="route('password.request')"
class="text-primary-600 hover:text-primary-700 mt-4 inline-block text-sm"
>
{{ __("I forgot my password") }} - Ho Dimenticato la
password
</Link>
</div>
</div>
<!-- Registrazione personalizzata (stile vecchia Blade) -->
<p
v-if="setting('reg_enabled')"
class="mt-6 text-center text-slate-500"
>
Non hai un account / Don't have an account?
<a
href="auth-creation.php"
class="text-primary-600 font-bold hover:text-primary-700"
>
Registrati / Sign Up
</a>
</p>
</AuthLayout>
</template>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Checkbox from '@/components/ui/Checkbox.vue';
import Dialog from '@/components/ui/Dialog.vue';
import Input from '@/components/ui/Input.vue';
import Message from '@/components/ui/Message.vue';
import PasswordInput from '@/components/ui/PasswordInput.vue';
import { ref } from 'vue';
import ReCaptcha from '@/components/ReCaptcha.vue';
import SocialButtons from '@/components/SocialButtons.vue';
import TermsOfService from '@/components/TermsOfService.vue';
import { useSettings, useTranslations } from '@/composables/useVanguard';
import AuthLayout from '@/layouts/AuthLayout.vue';
defineProps<{
socialProviders: string[] | null;
captchaSitekey: string | null;
}>();
const { __ } = useTranslations();
const { setting } = useSettings();
const tosVisible = ref(false);
const captcha = ref<InstanceType<typeof ReCaptcha> | null>(null);
const form = useForm({
email: '',
username: '',
password: '',
password_confirmation: '',
tos: false,
'g-recaptcha-response': '',
});
function submit() {
form.post('/register', {
onError: () => {
captcha.value?.reset();
form['g-recaptcha-response'] = '';
},
onFinish: () => form.reset('password', 'password_confirmation'),
});
}
</script>
<template>
<AuthLayout :title="__('Sign Up')">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mt-2 mb-4 text-center text-sm font-semibold tracking-wider text-slate-600 uppercase">
{{ __('Register') }}
</h5>
<SocialButtons :providers="socialProviders" />
<form class="space-y-4" autocomplete="off" @submit.prevent="submit">
<div>
<Input
id="email"
v-model="form.email"
type="email"
class="w-full"
:placeholder="__('Email')"
:invalid="Boolean(form.errors.email)"
autocomplete="email"
/>
<small v-if="form.errors.email" class="text-red-600">{{ form.errors.email }}</small>
</div>
<div>
<Input
id="username"
v-model="form.username"
class="w-full"
:placeholder="__('Username')"
:invalid="Boolean(form.errors.username)"
autocomplete="username"
/>
<small v-if="form.errors.username" class="text-red-600">
{{ form.errors.username }}
</small>
</div>
<div>
<PasswordInput
id="password"
v-model="form.password"
class="w-full"
:placeholder="__('Password')"
:invalid="Boolean(form.errors.password)"
autocomplete="new-password"
/>
<small v-if="form.errors.password" class="text-red-600">
{{ form.errors.password }}
</small>
</div>
<div>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
class="w-full"
:placeholder="__('Confirm Password')"
autocomplete="new-password"
/>
</div>
<div v-if="setting('tos')">
<div class="flex items-center gap-2">
<Checkbox
v-model="form.tos"
id="tos"
:invalid="Boolean(form.errors.tos)"
/>
<label for="tos" class="text-slate-600">
{{ __('I accept') }}
<button
type="button"
class="text-primary-600 cursor-pointer hover:underline"
@click="tosVisible = true"
>
{{ __('Terms of Service') }}
</button>
</label>
</div>
<small v-if="form.errors.tos" class="text-red-600">{{ form.errors.tos }}</small>
</div>
<div v-if="setting('registration.captcha.enabled') && captchaSitekey">
<ReCaptcha
ref="captcha"
:sitekey="captchaSitekey"
@verified="(token) => (form['g-recaptcha-response'] = token)"
/>
<Message
v-if="form.errors['g-recaptcha-response']"
severity="error"
class="mt-2"
>
{{ form.errors['g-recaptcha-response'] }}
</Message>
</div>
<Button
type="submit"
:label="__('Register')"
class="w-full"
size="large"
:loading="form.processing"
/>
</form>
</div>
</div>
<p class="mt-6 text-center text-slate-500">
{{ __('Already have an account?') }}
<Link href="/login" class="text-primary-600 font-bold">{{ __('Login') }}</Link>
</p>
<Dialog v-model:open="tosVisible" :header="__('Terms of Service')" width="40rem">
<TermsOfService />
<template #footer>
<Button :label="__('Close')" severity="secondary" @click="tosVisible = false" />
</template>
</Dialog>
</AuthLayout>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import PasswordInput from '@/components/ui/PasswordInput.vue';
import { useTranslations } from '@/composables/useVanguard';
import AuthLayout from '@/layouts/AuthLayout.vue';
const props = defineProps<{ token: string; email?: string | null }>();
const { __ } = useTranslations();
const form = useForm({
token: props.token,
email: props.email ?? '',
password: '',
password_confirmation: '',
});
function submit() {
form.post(route('password.update'), {
onFinish: () => form.reset('password', 'password_confirmation'),
});
}
</script>
<template>
<AuthLayout :title="__('Reset Password')">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mt-2 mb-2 text-center text-sm font-semibold tracking-wider text-slate-600 uppercase">
{{ __('Reset Your Password') }}
</h5>
<form class="space-y-4 p-2" autocomplete="off" @submit.prevent="submit">
<p class="mb-4 px-2 text-center font-light text-slate-500">
{{ __('Please provide your email and pick a new password below.') }}
</p>
<div>
<label for="email" class="sr-only">{{ __('Your E-Mail') }}</label>
<Input
id="email"
v-model="form.email"
type="email"
class="w-full"
:placeholder="__('Your E-Mail')"
:invalid="Boolean(form.errors.email)"
autocomplete="email"
/>
<small v-if="form.errors.email" class="text-red-600">{{ form.errors.email }}</small>
</div>
<div>
<label for="password" class="sr-only">{{ __('New Password') }}</label>
<PasswordInput
id="password"
v-model="form.password"
class="w-full"
:placeholder="__('New Password')"
:invalid="Boolean(form.errors.password)"
autocomplete="new-password"
/>
<small v-if="form.errors.password" class="text-red-600">
{{ form.errors.password }}
</small>
</div>
<div>
<label for="password_confirmation" class="sr-only">
{{ __('Confirm New Password') }}
</label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
class="w-full"
:placeholder="__('Confirm New Password')"
autocomplete="new-password"
/>
</div>
<Button
type="submit"
:label="__('Update Password')"
class="mt-6 w-full"
size="large"
:loading="form.processing"
/>
</form>
</div>
</div>
</AuthLayout>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import Message from '@/components/ui/Message.vue';
import { useTranslations } from '@/composables/useVanguard';
import AuthLayout from '@/layouts/AuthLayout.vue';
const { __ } = useTranslations();
const form = useForm({ code: '' });
function submit() {
form.post(route('auth.token.validate'), {
onFinish: () => form.reset('code'),
});
}
</script>
<template>
<AuthLayout :title="__('Two-Factor Authentication')">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mt-2 mb-4 text-center text-sm font-semibold tracking-wider text-slate-600 uppercase">
{{ __('Two-Factor Authentication') }}
</h5>
<Message v-if="form.errors.code" severity="error" class="mb-4">
{{ form.errors.code }}
</Message>
<form class="space-y-4" autocomplete="off" @submit.prevent="submit">
<div>
<label for="code" class="sr-only">{{ __('Token') }}</label>
<Input
id="code"
v-model="form.code"
class="w-full"
:placeholder="__('2FA Code')"
:invalid="Boolean(form.errors.code)"
inputmode="numeric"
autocomplete="one-time-code"
autofocus
/>
</div>
<Button
type="submit"
:label="__('Validate')"
class="w-full"
size="large"
:loading="form.processing"
/>
</form>
</div>
</div>
</AuthLayout>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import Message from '@/components/ui/Message.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
defineProps<{ resent?: boolean }>();
const { __ } = useTranslations();
function resend() {
router.post(route('verification.resend'));
}
</script>
<template>
<AppLayout :title="__('Verify Your Email Address')" :heading="__('Verify Your Email Address')">
<div class="mx-auto max-w-2xl">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div class="p-6">
<h5 class="mb-4 text-lg font-medium">{{ __('Verify Your Email Address') }}</h5>
<Message v-if="resent" severity="success" class="mb-4">
{{ __('A fresh verification link has been sent to your email address.') }}
</Message>
<p class="text-slate-600">
{{ __('Before proceeding, please check your email for a verification link.') }}
{{ __('If you did not receive the email') }},
<button
type="button"
class="text-primary-600 cursor-pointer hover:underline"
@click="resend"
>
{{ __('click here to request another') }}</button>.
</p>
</div>
</div>
</div>
</AppLayout>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import Textarea from '@/components/ui/Textarea.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface Permission {
id: number;
name: string;
display_name: string | null;
description: string | null;
}
const props = defineProps<{ edit: boolean; permission?: Permission | null }>();
const { __ } = useTranslations();
const form = useForm({
name: props.permission?.name ?? '',
display_name: props.permission?.display_name ?? '',
description: props.permission?.description ?? '',
});
function submit() {
if (props.edit && props.permission) {
form.put(route('permissions.update', props.permission.id));
return;
}
form.post(route('permissions.store'));
}
</script>
<template>
<AppLayout
:title="__('Permissions')"
:heading="edit && permission ? permission.name : __('Create New Permission')"
:breadcrumbs="[
{ label: __('Permissions'), href: route('permissions.index') },
{ label: edit ? __('Edit') : __('Create') },
]"
>
<form @submit.prevent="submit">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
<div class="md:col-span-1">
<h5 class="font-medium text-slate-800">{{ __('Permission Details') }}</h5>
<p class="font-light text-slate-500">
{{ __('A general permission information.') }}
</p>
</div>
<div class="space-y-4 md:col-span-3">
<div>
<label for="name" class="mb-1 block text-slate-700">{{ __('Name') }}</label>
<Input
id="name"
v-model="form.name"
class="w-full"
:placeholder="__('Permission Name')"
:invalid="Boolean(form.errors.name)"
/>
<small v-if="form.errors.name" class="text-red-600">{{ form.errors.name }}</small>
</div>
<div>
<label for="display_name" class="mb-1 block text-slate-700">
{{ __('Display Name') }}
</label>
<Input
id="display_name"
v-model="form.display_name"
class="w-full"
:placeholder="__('Display Name')"
:invalid="Boolean(form.errors.display_name)"
/>
<small v-if="form.errors.display_name" class="text-red-600">
{{ form.errors.display_name }}
</small>
</div>
<div>
<label for="description" class="mb-1 block text-slate-700">
{{ __('Description') }}
</label>
<Textarea
id="description"
v-model="form.description"
class="w-full"
:rows="3"
:invalid="Boolean(form.errors.description)"
/>
<small v-if="form.errors.description" class="text-red-600">
{{ form.errors.description }}
</small>
</div>
</div>
</div>
</div>
<Button
type="submit"
:label="edit ? __('Update Permission') : __('Create Permission')"
class="mt-4"
:loading="form.processing"
/>
</form>
</AppLayout>
</template>
+160
View File
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Checkbox from '@/components/ui/Checkbox.vue';
import Tooltip from '@/components/ui/Tooltip.vue';
import { useConfirmDelete } from '@/composables/useConfirmDelete';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface Role {
id: number;
display_name: string | null;
}
interface Permission {
id: number;
name: string;
display_name: string | null;
removable: boolean;
editUrl: string;
deleteUrl: string;
}
const props = defineProps<{
roles: Role[];
permissions: Permission[];
/** Permission ids currently granted, keyed by role id. */
granted: Record<number, number[]>;
}>();
const { __ } = useTranslations();
const { confirmDelete } = useConfirmDelete();
/**
* Mirrors the old roles[{roleId}][] checkbox matrix: each role maps to the
* list of permission ids it holds, and RolePermissionsController replaces the
* whole set for every role on save.
*/
const form = useForm<{ roles: Record<number, number[]> }>({
roles: Object.fromEntries(
props.roles.map((role) => [role.id, [...(props.granted[role.id] ?? [])]]),
),
});
function remove(permission: Permission) {
confirmDelete({
url: permission.deleteUrl,
message: __('Are you sure that you want to delete this permission?'),
});
}
function submit() {
form.post(route('permissions.save'), { preserveScroll: true });
}
</script>
<template>
<AppLayout
:title="__('Permissions')"
:heading="__('Permissions')"
:breadcrumbs="[{ label: __('Permissions') }]"
>
<form @submit.prevent="submit">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="mb-4 flex justify-end border-b border-slate-100 pb-4">
<Link :href="route('permissions.create')">
<Button :label="__('Add Permission')" icon="fas fa-plus" rounded />
</Link>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-3xl text-left">
<thead>
<tr class="border-b border-slate-100">
<th class="px-4 py-3 font-medium">{{ __('Name') }}</th>
<th
v-for="role in roles"
:key="role.id"
class="px-4 py-3 text-center font-medium"
>
{{ role.display_name }}
</th>
<th class="px-4 py-3 text-center font-medium">{{ __('Action') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="permission in permissions"
:key="permission.id"
class="odd:bg-body border-b border-slate-50"
>
<td class="px-4 py-3">
{{ permission.display_name || permission.name }}
</td>
<td
v-for="role in roles"
:key="role.id"
class="px-4 py-3 text-center"
>
<!--
Checkbox renders a flex box, which the
cell's text-center cannot reach, so it
has to centre itself.
-->
<Checkbox
v-model="form.roles[role.id]"
:value="permission.id"
:id="`cb-${role.id}-${permission.id}`"
class="mx-auto"
/>
</td>
<td class="px-4 py-3 text-center">
<Tooltip :text="__('Edit Permission')">
<Link :href="permission.editUrl">
<Button
icon="fas fa-edit"
text
rounded
severity="secondary"
:aria-label="__('Edit Permission')"
/>
</Link>
</Tooltip>
<Tooltip v-if="permission.removable" :text="__('Delete Permission')">
<Button
icon="fas fa-trash"
text
rounded
severity="secondary"
:aria-label="__('Delete Permission')"
@click="remove(permission)"
/>
</Tooltip>
</td>
</tr>
<tr v-if="!permissions.length">
<td :colspan="roles.length + 2" class="px-4 py-3">
<em class="text-slate-500">{{ __('No records found.') }}</em>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Button
v-if="permissions.length"
type="submit"
:label="__('Save Permissions')"
class="mt-4"
:loading="form.processing"
/>
</form>
</AppLayout>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import Textarea from '@/components/ui/Textarea.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface Role {
id: number;
name: string;
display_name: string | null;
description: string | null;
}
const props = defineProps<{ edit: boolean; role?: Role | null }>();
const { __ } = useTranslations();
const form = useForm({
name: props.role?.name ?? '',
display_name: props.role?.display_name ?? '',
description: props.role?.description ?? '',
});
function submit() {
if (props.edit && props.role) {
form.put(route('roles.update', props.role.id));
return;
}
form.post(route('roles.store'));
}
</script>
<template>
<AppLayout
:title="__('Roles')"
:heading="edit && role ? role.name : __('Create New Role')"
:breadcrumbs="[
{ label: __('Roles'), href: route('roles.index') },
{ label: edit ? __('Edit') : __('Create') },
]"
>
<form @submit.prevent="submit">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
<div class="md:col-span-1">
<h5 class="font-medium text-slate-800">{{ __('Role Details') }}</h5>
<p class="text-slate-500">{{ __('A general role information.') }}</p>
</div>
<div class="space-y-4 md:col-span-3">
<div>
<label for="name" class="mb-1 block text-slate-700">{{ __('Name') }}</label>
<Input
id="name"
v-model="form.name"
class="w-full"
:placeholder="__('Role Name')"
:invalid="Boolean(form.errors.name)"
/>
<small v-if="form.errors.name" class="text-red-600">{{ form.errors.name }}</small>
</div>
<div>
<label for="display_name" class="mb-1 block text-slate-700">
{{ __('Display Name') }}
</label>
<Input
id="display_name"
v-model="form.display_name"
class="w-full"
:placeholder="__('Display Name')"
:invalid="Boolean(form.errors.display_name)"
/>
<small v-if="form.errors.display_name" class="text-red-600">
{{ form.errors.display_name }}
</small>
</div>
<div>
<label for="description" class="mb-1 block text-slate-700">
{{ __('Description') }}
</label>
<Textarea
id="description"
v-model="form.description"
class="w-full"
:rows="3"
:invalid="Boolean(form.errors.description)"
/>
<small v-if="form.errors.description" class="text-red-600">
{{ form.errors.description }}
</small>
</div>
</div>
</div>
</div>
<Button
type="submit"
:label="edit ? __('Update Role') : __('Create Role')"
class="mt-4"
:loading="form.processing"
/>
</form>
</AppLayout>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Tooltip from '@/components/ui/Tooltip.vue';
import { useConfirmDelete } from '@/composables/useConfirmDelete';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface Role {
id: number;
name: string;
display_name: string | null;
users_count: number;
removable: boolean;
editUrl: string;
deleteUrl: string;
}
defineProps<{ roles: Role[] }>();
const { __ } = useTranslations();
const { confirmDelete } = useConfirmDelete();
function remove(role: Role) {
confirmDelete({
url: role.deleteUrl,
message: __('Are you sure that you want to delete this role?'),
});
}
</script>
<template>
<AppLayout :title="__('Roles')" :heading="__('Roles')" :breadcrumbs="[{ label: __('Roles') }]">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="mb-4 flex justify-end border-b border-slate-100 pb-4">
<Link :href="route('roles.create')">
<Button :label="__('Add Role')" icon="fas fa-plus" rounded />
</Link>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-2xl text-left">
<thead>
<tr class="border-b border-slate-100">
<th class="px-4 py-3 font-medium">{{ __('Name') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Display Name') }}</th>
<th class="px-4 py-3 font-medium">
{{ __('# of users with this role') }}
</th>
<th class="w-40 px-4 py-3 text-center font-medium">{{ __('Action') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!roles.length">
<td colspan="4" class="px-4 py-3">
<em class="text-slate-500">{{ __('No records found.') }}</em>
</td>
</tr>
<tr
v-for="role in roles"
:key="role.id"
class="odd:bg-body border-b border-slate-50"
>
<td class="px-4 py-3">{{ role.name }}</td>
<td class="px-4 py-3">{{ role.display_name }}</td>
<td class="px-4 py-3">{{ role.users_count }}</td>
<td class="px-4 py-3 text-center">
<Tooltip :text="__('Edit Role')">
<Link :href="role.editUrl">
<Button
icon="fas fa-edit"
text
rounded
severity="secondary"
:aria-label="__('Edit Role')"
/>
</Link>
</Tooltip>
<Tooltip v-if="role.removable" :text="__('Delete Role')">
<Button
icon="fas fa-trash"
text
rounded
severity="secondary"
:aria-label="__('Delete Role')"
@click="remove(role)"
/>
</Tooltip>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
</template>
+262
View File
@@ -0,0 +1,262 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import Message from '@/components/ui/Message.vue';
import Tabs from '@/components/ui/Tabs.vue';
import FeatureToggleCard from '@/components/FeatureToggleCard.vue';
import SettingsCard from '@/components/SettingsCard.vue';
import ToggleField from '@/components/ToggleField.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
import { ref } from 'vue';
const props = defineProps<{
values: Record<string, string | number>;
captchaConfigured: boolean;
}>();
const { __ } = useTranslations();
const general = useForm({
remember_me: Number(props.values.remember_me ?? 0),
forgot_password: Number(props.values.forgot_password ?? 0),
login_reset_token_lifetime: String(props.values.login_reset_token_lifetime ?? 30),
max_active_sessions: String(props.values.max_active_sessions ?? 0),
});
const throttling = useForm({
throttle_enabled: Number(props.values.throttle_enabled ?? 0),
throttle_attempts: String(props.values.throttle_attempts ?? 10),
throttle_lockout_time: String(props.values.throttle_lockout_time ?? 1),
});
const registration = useForm({
reg_enabled: Number(props.values.reg_enabled ?? 0),
tos: Number(props.values.tos ?? 0),
reg_email_confirmation: Number(props.values.reg_email_confirmation ?? 0),
});
function submit(form: { post: (url: string, options: object) => void }) {
form.post(route('settings.auth.update'), { preserveScroll: true });
}
/**
* Keys containing quotes or ampersands are declared here rather than inline in
* the template, so the string handed to __() matches the language file key
* exactly and is not altered by HTML entity decoding.
*/
const labels = {
rememberMe: __('Allow "Remember Me"'),
rememberMeHint: __("Should 'Remember Me' checkbox be displayed on login form?"),
terms: __('Terms & Conditions'),
};
const activeTab = ref('auth');
const tabs = [
{ value: 'auth', label: __('Authentication'), icon: 'fa fa-lock' },
{ value: 'registration', label: __('Registration'), icon: 'fa fa-user-plus' },
];
</script>
<template>
<AppLayout
:title="__('Authentication Settings')"
:heading="__('Authentication')"
:breadcrumbs="[{ label: __('Settings') }, { label: __('Authentication') }]"
>
<Tabs v-model="activeTab" :tabs="tabs">
<template #auth>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-4">
<SettingsCard :title="__('General')">
<form class="space-y-6" @submit.prevent="submit(general)">
<ToggleField
v-model="general.remember_me"
input-id="switch-remember-me"
:label="labels.rememberMe"
:description="labels.rememberMeHint"
/>
<ToggleField
v-model="general.forgot_password"
input-id="switch-forgot-pass"
:label="__('Forgot Password')"
:description="__('Enable/Disable forgot password feature.')"
/>
<div>
<label for="login_reset_token_lifetime" class="block text-slate-700">
{{ __('Reset Token Lifetime') }}
</label>
<small class="mb-1 block text-slate-500">
{{ __('Number of minutes that the reset token should be considered valid.') }}
</small>
<Input
id="login_reset_token_lifetime"
v-model="general.login_reset_token_lifetime"
class="w-full"
/>
</div>
<div>
<label for="max_active_sessions" class="block text-slate-700">
{{ __('Max Number of Active Sessions') }}
</label>
<small class="mb-1 block text-slate-500">
{{ __('Maximum number of active sessions per user. Set to 0 to allow unlimited number of active sessions.') }}
{{ __('Only applies when using database session driver.') }}
</small>
<Input
id="max_active_sessions"
v-model="general.max_active_sessions"
class="w-full"
/>
</div>
<Button
type="submit"
:label="__('Update')"
:loading="general.processing"
/>
</form>
</SettingsCard>
<FeatureToggleCard
:title="__('Two-Factor Authentication')"
:description="__('Enable/Disable Two-Factor Authentication for the application.')"
:enabled="Boolean(Number(values['2fa.enabled']))"
:enable-url="route('settings.auth.2fa.enable')"
:disable-url="route('settings.auth.2fa.disable')"
/>
</div>
<div class="space-y-4">
<SettingsCard :title="__('Authentication Throttling')">
<form class="space-y-6" @submit.prevent="submit(throttling)">
<ToggleField
v-model="throttling.throttle_enabled"
input-id="switch-throttle"
:label="__('Throttle Authentication')"
:description="__('Should the system throttle authentication attempts?')"
/>
<div>
<label for="throttle_attempts" class="block text-slate-700">
{{ __('Maximum Number of Attempts') }}
</label>
<small class="mb-1 block text-slate-500">
{{ __('Maximum number of incorrect login attempts before lockout.') }}
</small>
<Input
id="throttle_attempts"
v-model="throttling.throttle_attempts"
class="w-full"
/>
</div>
<div>
<label for="throttle_lockout_time" class="block text-slate-700">
{{ __('Lockout Time') }}
</label>
<small class="mb-1 block text-slate-500">
{{ __('Number of minutes to lock the user out for after specified maximum number of incorrect login attempts.') }}
</small>
<Input
id="throttle_lockout_time"
v-model="throttling.throttle_lockout_time"
class="w-full"
/>
</div>
<Button
type="submit"
:label="__('Update')"
:loading="throttling.processing"
/>
</form>
</SettingsCard>
<FeatureToggleCard
:title="__('User Confirmation Flow')"
:description="__('Enable/Disable the user confirmation flow for the application. When enabled, users will be set to Waiting Approval and require admin approval before becoming Active.')"
:enabled="Boolean(Number(values['approval.enabled']))"
:enable-url="route('settings.auth.approval.enable')"
:disable-url="route('settings.auth.approval.disable')"
/>
<FeatureToggleCard
:title="__('Force Password Change On Next Login')"
:description="__('Force all the users created manually by admin to reset the password on their first log in to be able to use the application.')"
:enabled="Boolean(Number(values['password-change.enabled']))"
:enable-url="route('settings.auth.password-change.enable')"
:disable-url="route('settings.auth.password-change.disable')"
/>
</div>
</div>
</template>
<template #registration>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<SettingsCard :title="__('General')">
<form class="space-y-6" @submit.prevent="submit(registration)">
<ToggleField
v-model="registration.reg_enabled"
input-id="switch-reg-enabled"
:label="__('Allow Registration')"
/>
<ToggleField
v-model="registration.tos"
input-id="switch-tos"
:label="labels.terms"
:description="__('The user has to confirm that he agrees with terms and conditions in order to create an account.')"
/>
<ToggleField
v-model="registration.reg_email_confirmation"
input-id="switch-reg-email-confirm"
:label="__('Email Confirmation')"
:description="__('Require email confirmation from your newly registered users.')"
/>
<Button
type="submit"
:label="__('Update')"
:loading="registration.processing"
/>
</form>
</SettingsCard>
<FeatureToggleCard
:title="__('Google reCAPTCHA')"
:description="__('Enable/Disable Google reCAPTCHA during the registration.')"
:enabled="Boolean(Number(values['registration.captcha.enabled']))"
:enable-url="route('settings.registration.captcha.enable')"
:disable-url="route('settings.registration.captcha.disable')"
>
<template v-if="!captchaConfigured" #notice>
<Message severity="info">
{{ __('To utilize Google reCAPTCHA, please get your') }}
<code>{{ __('Site Key') }}</code>
{{ __('and') }} <code>{{ __('Secret Key') }}</code>
{{ __('from') }}
<a
href="https://www.google.com/recaptcha/intro/index.html"
target="_blank"
rel="noopener"
class="font-bold underline"
>{{ __('reCAPTCHA Website') }}</a>,
{{ __('and update your') }} <code>RECAPTCHA_SITEKEY</code>
{{ __('and') }} <code>RECAPTCHA_SECRETKEY</code>
{{ __('environment variables inside') }} <code>.env</code>
{{ __('file') }}.
</Message>
</template>
</FeatureToggleCard>
</div>
</template>
</Tabs>
</AppLayout>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import SettingsCard from '@/components/SettingsCard.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{ values: { app_name: string } }>();
const { __ } = useTranslations();
const form = useForm({ app_name: props.values.app_name ?? '' });
function submit() {
form.post(route('settings.general.update'), { preserveScroll: true });
}
</script>
<template>
<AppLayout
:title="__('General Settings')"
:heading="__('General Settings')"
:breadcrumbs="[{ label: __('Settings') }, { label: __('General') }]"
>
<form class="max-w-3xl" @submit.prevent="submit">
<SettingsCard>
<div>
<label for="app_name" class="mb-1 block text-slate-700">{{ __('Name') }}</label>
<Input
id="app_name"
v-model="form.app_name"
class="w-full"
:invalid="Boolean(form.errors.app_name)"
/>
<small v-if="form.errors.app_name" class="text-red-600">
{{ form.errors.app_name }}
</small>
</div>
</SettingsCard>
<Button
type="submit"
:label="__('Update')"
class="mt-4"
:loading="form.processing"
/>
</form>
</AppLayout>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import Button from '@/components/ui/Button.vue';
import SettingsCard from '@/components/SettingsCard.vue';
import ToggleField from '@/components/ToggleField.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{ values: { notifications_signup_email: number } }>();
const { __ } = useTranslations();
const form = useForm({
notifications_signup_email: Number(props.values.notifications_signup_email ?? 0),
});
function submit() {
form.post(route('settings.notifications.update'), { preserveScroll: true });
}
</script>
<template>
<AppLayout
:title="__('Notification Settings')"
:heading="__('Notification Settings')"
:breadcrumbs="[{ label: __('Settings') }, { label: __('Notifications') }]"
>
<form class="max-w-2xl" @submit.prevent="submit">
<SettingsCard :title="__('Email Notifications')">
<ToggleField
v-model="form.notifications_signup_email"
input-id="switch-signup-email"
:label="__('Sign-Up Notification')"
:description="__('Send an email to the Administrators when user signs up.')"
/>
<Button
type="submit"
:label="__('Update')"
class="mt-6"
:loading="form.processing"
/>
</SettingsCard>
</form>
</AppLayout>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import LoginDetailsForm from '@/components/users/LoginDetailsForm.vue';
import UserDetailsForm from '@/components/users/UserDetailsForm.vue';
import Button from '@/components/ui/Button.vue';
import type { SelectOption } from '@/components/ui/Select.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
import type { LoginDetailsFormData, UserDetailsFormData } from '@/types';
const props = defineProps<{
roles: SelectOption[];
statuses: SelectOption[];
countries: SelectOption[];
defaultRoleId: number | null;
}>();
const { __ } = useTranslations();
const form = useForm<UserDetailsFormData & LoginDetailsFormData>({
role_id: props.defaultRoleId ?? props.roles[0]?.value ?? '',
status: props.statuses[0]?.value ?? '',
first_name: '',
last_name: '',
birthday: '',
phone: '',
address: '',
country_id: 0,
email: '',
username: '',
password: '',
password_confirmation: '',
});
function submit(): void {
form.post(route('users.store'));
}
</script>
<template>
<AppLayout
:title="__('Add User')"
:heading="__('Create New User')"
:breadcrumbs="[{ label: __('Users'), href: route('users.index') }, { label: __('Create') }]"
>
<form class="space-y-4" @submit.prevent="submit">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
<div>
<h5 class="font-medium text-slate-800">{{ __('User Details') }}</h5>
<p class="font-light text-slate-500">
{{ __('A general user profile information.') }}
</p>
</div>
<div class="md:col-span-3">
<UserDetailsForm
v-model="form"
:roles="roles"
:statuses="statuses"
:countries="countries"
:errors="form.errors"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="grid grid-cols-1 gap-6 md:grid-cols-4">
<div>
<h5 class="font-medium text-slate-800">{{ __('Login Details') }}</h5>
<p class="font-light text-slate-500">
{{ __('Details used for authenticating with the application.') }}
</p>
</div>
<div class="md:col-span-3">
<LoginDetailsForm v-model="form" :errors="form.errors" />
</div>
</div>
</div>
<Button type="submit" :label="__('Create User')" :loading="form.processing" />
</form>
</AppLayout>
</template>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import AvatarCard from '@/components/users/AvatarCard.vue';
import LoginDetailsForm from '@/components/users/LoginDetailsForm.vue';
import TwoFactorPanel from '@/components/users/TwoFactorPanel.vue';
import UserDetailsForm from '@/components/users/UserDetailsForm.vue';
import Button from '@/components/ui/Button.vue';
import Message from '@/components/ui/Message.vue';
import type { SelectOption } from '@/components/ui/Select.vue';
import Tabs from '@/components/ui/Tabs.vue';
import { useSettings, useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
import type {
AvatarSource,
LoginDetailsFormData,
TwoFactorState,
UserDetailsFormData,
UserFormPayload,
} from '@/types';
const props = defineProps<{
user: UserFormPayload;
roles: SelectOption[];
statuses: SelectOption[];
countries: SelectOption[];
avatarSources: AvatarSource[];
twoFactor: TwoFactorState;
showForcePasswordChange: boolean;
activeTab?: string;
}>();
const { __ } = useTranslations();
const { setting } = useSettings();
const details = useForm<UserDetailsFormData>({
role_id: props.user.role_id,
status: props.user.status,
first_name: props.user.first_name,
last_name: props.user.last_name,
birthday: props.user.birthday,
phone: props.user.phone,
address: props.user.address,
country_id: props.user.country_id,
});
const login = useForm<LoginDetailsFormData>({
email: props.user.email,
username: props.user.username,
password: '',
password_confirmation: '',
force_password_change: props.user.force_password_change,
});
const approving = ref(false);
const tabs = computed(() => {
const items = [
{ value: 'details', label: __('User Details') },
{ value: 'login', label: __('Login Details') },
];
if (setting('2fa.enabled')) {
items.push({ value: '2fa', label: __('Two-Factor Authentication') });
}
return items;
});
const tab = ref(props.activeTab ?? 'details');
function approve(): void {
approving.value = true;
router.put(
route('users.update.approve', props.user.id),
{},
{ preserveScroll: true, onFinish: () => (approving.value = false) },
);
}
</script>
<template>
<AppLayout
:title="__('Edit User')"
:heading="user.nameOrEmail"
:breadcrumbs="[
{ label: __('Users'), href: route('users.index') },
{ label: user.nameOrEmail, href: route('users.show', user.id) },
{ label: __('Edit') },
]"
>
<Message v-if="user.waitingApproval" severity="warn" class="mb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<span>
{{ __('The user is waiting on your approval to be able to access the website.') }}
</span>
<Button
icon="fa fa-refresh"
:label="__('Approve')"
:loading="approving"
@click="approve"
/>
</div>
</Message>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<Tabs v-model="tab" :tabs="tabs">
<template #details>
<form
@submit.prevent="
details.put(route('users.update.details', user.id), {
preserveScroll: true,
})
"
>
<UserDetailsForm
v-model="details"
:roles="roles"
:statuses="statuses"
:countries="countries"
:errors="details.errors"
:submit-label="__('Update Details')"
:processing="details.processing"
/>
</form>
</template>
<template #login>
<form
@submit.prevent="
login.put(route('users.update.login-details', user.id), {
preserveScroll: true,
})
"
>
<LoginDetailsForm
v-model="login"
edit
:errors="login.errors"
:show-force-password-change="showForcePasswordChange"
:submit-label="__('Update Details')"
:processing="login.processing"
/>
</form>
</template>
<template #2fa>
<TwoFactorPanel :user-id="user.id" :two-factor="twoFactor" />
</template>
</Tabs>
</div>
</div>
<AvatarCard
:avatar="user.avatar"
:name-or-email="user.nameOrEmail"
:sources="avatarSources"
:upload-url="route('user.update.avatar', user.id)"
:external-url="route('user.update.avatar.external', user.id)"
/>
</div>
</AppLayout>
</template>
+238
View File
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { ref, watch } from 'vue';
import StatusBadge from '@/components/users/StatusBadge.vue';
import Button from '@/components/ui/Button.vue';
import Input from '@/components/ui/Input.vue';
import Menu from '@/components/ui/Menu.vue';
import MenuItem from '@/components/ui/MenuItem.vue';
import Pagination, { type PaginatorLink } from '@/components/ui/Pagination.vue';
import Select, { type SelectOption } from '@/components/ui/Select.vue';
import Tooltip from '@/components/ui/Tooltip.vue';
import { useConfirmDelete } from '@/composables/useConfirmDelete';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface UserRow {
id: number;
username: string | null;
fullName: string;
email: string;
avatar: string;
nameOrEmail: string;
registeredAt: string;
statusLabel: string;
statusVariant: string;
waitingApproval: boolean;
canImpersonate: boolean;
}
const props = defineProps<{
users: { data: UserRow[]; links: PaginatorLink[] };
statuses: SelectOption[];
filters: { search: string; status: string };
databaseSessions: boolean;
}>();
const { __ } = useTranslations();
const { confirmDelete } = useConfirmDelete();
const search = ref(props.filters.search);
const status = ref(props.filters.status);
/**
* Replaces the GET form the Blade page submitted on every keystroke of the
* search button and on every status change. Debounced so typing does not fire
* a request per character.
*/
let timer: ReturnType<typeof setTimeout> | undefined;
watch([search, status], () => {
clearTimeout(timer);
timer = setTimeout(() => {
router.get(
route('users.index'),
{ search: search.value || undefined, status: status.value || undefined },
{ preserveState: true, replace: true },
);
}, 300);
});
function remove(user: UserRow): void {
confirmDelete({
url: route('users.destroy', user.id),
message: __('Are you sure that you want to delete this user?'),
confirmLabel: __('Yes, delete him!'),
});
}
function approve(user: UserRow): void {
confirmDelete({
url: route('users.update.approve', user.id),
method: 'put',
message: __('Are you sure that you want to approve this user?'),
confirmLabel: __('Approve'),
});
}
</script>
<template>
<AppLayout :title="__('Users')" :heading="__('Users')" :breadcrumbs="[{ label: __('Users') }]">
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div
class="mb-4 flex flex-col gap-3 border-b border-slate-100 pb-4 md:flex-row md:items-center"
>
<div class="relative md:w-72">
<label for="search" class="sr-only">{{ __('Search for users...') }}</label>
<Input
id="search"
v-model="search"
class="w-full pr-9"
:placeholder="__('Search for users...')"
/>
<button
v-if="search"
type="button"
class="absolute inset-y-0 right-0 flex w-9 cursor-pointer items-center justify-center text-slate-400 hover:text-slate-600"
:aria-label="__('Cancel')"
@click="search = ''"
>
<i class="fas fa-times" aria-hidden="true" />
</button>
</div>
<div class="md:w-48">
<label for="status" class="sr-only">{{ __('Status') }}</label>
<Select id="status" v-model="status" :options="statuses" />
</div>
<Link :href="route('users.create')" class="md:ml-auto">
<Button :label="__('Add User')" icon="fas fa-plus" rounded />
</Link>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-3xl text-left">
<thead>
<tr class="border-b border-slate-100">
<th class="w-14 px-4 py-3" />
<th class="px-4 py-3 font-medium">{{ __('Username') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Full Name') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Email') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Registration Date') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Status') }}</th>
<th class="px-4 py-3 text-center font-medium">{{ __('Action') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!users.data.length">
<td colspan="7" class="px-4 py-3">
<em class="text-slate-500">{{ __('No records found.') }}</em>
</td>
</tr>
<tr
v-for="user in users.data"
:key="user.id"
class="odd:bg-body border-b border-slate-50"
>
<td>
<Link :href="route('users.show', user.id)" class="flex items-center justify-center">
<img
:src="user.avatar"
width="40"
height="40"
:alt="user.nameOrEmail"
class="size-10 rounded-full object-cover"
/>
</Link>
</td>
<td class="px-4 py-3">
<Link
:href="route('users.show', user.id)"
class="text-primary-600 hover:underline"
>
{{ user.username || __('N/A') }}
</Link>
</td>
<td class="px-4 py-3">{{ user.fullName }}</td>
<td class="px-4 py-3">{{ user.email }}</td>
<td class="px-4 py-3">{{ user.registeredAt }}</td>
<td class="px-4 py-3">
<StatusBadge :label="user.statusLabel" :variant="user.statusVariant" />
</td>
<td class="px-4 py-3 text-center whitespace-nowrap">
<Menu>
<template #trigger>
<Button
icon="fas fa-ellipsis-h"
text
rounded
severity="secondary"
:aria-label="__('Action')"
/>
</template>
<MenuItem
v-if="databaseSessions"
:href="route('user.sessions', user.id)"
icon="fas fa-list"
>
{{ __('User Sessions') }}
</MenuItem>
<MenuItem :href="route('users.show', user.id)" icon="fas fa-eye">
{{ __('View User') }}
</MenuItem>
<MenuItem
v-if="user.waitingApproval"
icon="fas fa-check-square"
@select="approve(user)"
>
{{ __('Approve') }}
</MenuItem>
<MenuItem
v-if="user.canImpersonate"
:href="route('impersonate', user.id)"
icon="fas fa-user-secret"
>
{{ __('Impersonate') }}
</MenuItem>
</Menu>
<Tooltip :text="__('Edit User')">
<Link :href="route('users.edit', user.id)">
<Button
icon="fas fa-edit"
text
rounded
severity="secondary"
:aria-label="__('Edit User')"
/>
</Link>
</Tooltip>
<Tooltip :text="__('Delete User')">
<Button
icon="fas fa-trash"
text
rounded
severity="secondary"
:aria-label="__('Delete User')"
@click="remove(user)"
/>
</Tooltip>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination :links="users.links" />
</div>
</AppLayout>
</template>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import Button from '@/components/ui/Button.vue';
import Tooltip from '@/components/ui/Tooltip.vue';
import { useConfirmDelete } from '@/composables/useConfirmDelete';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
interface UserSession {
id: string;
ipAddress: string;
device: string;
platform: string;
browser: string;
lastActivity: string;
invalidateUrl: string;
}
const props = defineProps<{
user: { id: number; nameOrEmail: string };
sessions: UserSession[];
/** Admin viewing another user, rather than a user viewing their own. */
adminView: boolean;
}>();
const { __ } = useTranslations();
const { confirmDelete } = useConfirmDelete();
const breadcrumbs = props.adminView
? [
{ label: __('Users'), href: route('users.index') },
{ label: props.user.nameOrEmail, href: route('users.show', props.user.id) },
{ label: __('Sessions') },
]
: [{ label: __('Sessions') }];
function invalidate(session: UserSession): void {
confirmDelete({
url: session.invalidateUrl,
message: __('Are you sure that you want to invalidate this session?'),
confirmLabel: __('Yes, proceed!'),
});
}
</script>
<template>
<AppLayout
:title="`${user.nameOrEmail} - ${__('Active Sessions')}`"
:heading="`${__('Sessions')} (${user.nameOrEmail})`"
:breadcrumbs="breadcrumbs"
>
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<div class="overflow-x-auto">
<table class="w-full min-w-2xl text-left">
<thead>
<tr class="border-b border-slate-100">
<th class="px-4 py-3 font-medium">{{ __('IP Address') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Device') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Browser') }}</th>
<th class="px-4 py-3 font-medium">{{ __('Last Activity') }}</th>
<th class="px-4 py-3 text-center font-medium">{{ __('Action') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!sessions.length">
<td colspan="5" class="px-4 py-3">
<em class="text-slate-500">{{ __('No records found.') }}</em>
</td>
</tr>
<tr
v-for="session in sessions"
:key="session.id"
class="odd:bg-body border-b border-slate-50"
>
<td class="px-4 py-3">{{ session.ipAddress }}</td>
<td class="px-4 py-3">{{ session.device }} ({{ session.platform }})</td>
<td class="px-4 py-3">{{ session.browser }}</td>
<td class="px-4 py-3">{{ session.lastActivity }}</td>
<td class="px-4 py-3 text-center">
<Tooltip :text="__('Invalidate Session')">
<Button
icon="fas fa-times"
text
rounded
severity="secondary"
:aria-label="__('Invalidate Session')"
@click="invalidate(session)"
/>
</Tooltip>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
</template>
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import PluginSlot from '@/components/PluginSlot.vue';
import { useTranslations } from '@/composables/useVanguard';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
user: {
id: number;
name: string;
nameOrEmail: string;
email: string;
avatar: string;
phone: string | null;
birthday: string;
fullAddress: string;
lastLogin: string;
};
canImpersonate: boolean;
slots: Array<{ component: string; props: Record<string, unknown> }>;
}>();
const { __ } = useTranslations();
</script>
<template>
<AppLayout
:title="user.nameOrEmail"
:heading="user.nameOrEmail"
:breadcrumbs="[
{ label: __('Users'), href: route('users.index') },
{ label: user.nameOrEmail },
]"
>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<div :class="props.slots.length ? 'lg:col-span-5 xl:col-span-4' : 'lg:col-span-6 lg:col-start-4'">
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
<div
class="flex items-center justify-between border-b border-slate-100 px-6 py-4"
>
<h6 class="font-medium text-slate-700">{{ __('Details') }}</h6>
<small class="flex items-center gap-2">
<template v-if="canImpersonate">
<a
:href="route('impersonate', user.id)"
class="text-primary-600 hover:underline"
>
{{ __('Impersonate') }}
</a>
<span class="text-slate-300">|</span>
</template>
<Link
:href="route('users.edit', user.id)"
class="text-primary-600 hover:underline"
>
{{ __('Edit') }}
</Link>
</small>
</div>
<div class="p-6">
<div class="flex flex-col items-center pt-3">
<img
:src="user.avatar"
width="130"
height="130"
:alt="user.nameOrEmail"
class="mb-4 size-[130px] rounded-full border border-slate-200 object-cover p-1"
/>
<h5 v-if="user.name" class="text-lg font-medium text-slate-800">
{{ user.name }}
</h5>
<a :href="`mailto:${user.email}`" class="mb-2 font-light text-slate-500">
{{ user.email }}
</a>
</div>
<ul class="mt-3 divide-y divide-slate-100 border-t border-slate-100">
<li v-if="user.phone" class="py-3">
<strong>{{ __('Phone') }}:</strong>
<a :href="`tel:${user.phone}`" class="text-primary-600">
{{ user.phone }}
</a>
</li>
<li class="py-3">
<strong>{{ __('Birthday') }}:</strong> {{ user.birthday }}
</li>
<li class="py-3">
<strong>{{ __('Address') }}:</strong> {{ user.fullAddress }}
</li>
<li class="py-3">
<strong>{{ __('Last Logged In') }}:</strong> {{ user.lastLogin }}
</li>
</ul>
</div>
</div>
</div>
<div v-if="props.slots.length" class="lg:col-span-7 xl:col-span-8">
<PluginSlot name="user:show" :payloads="props.slots" />
</div>
</div>
</AppLayout>
</template>