first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user