first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
@@ -0,0 +1,45 @@
|
||||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { createApp, h, type DefineComponent } from 'vue';
|
||||
|
||||
import * as inertia from '@inertiajs/vue3';
|
||||
import * as vue from 'vue';
|
||||
import { ZiggyVue } from 'ziggy-js';
|
||||
import * as ui from './plugin-api';
|
||||
import { installVanguardRuntime, resolvePage } from './vanguard';
|
||||
|
||||
const appName = document.title || 'Vanguard';
|
||||
|
||||
/**
|
||||
* Expose the shared runtime before Inertia resolves the first page so plugin
|
||||
* bundles that loaded ahead of us are drained and their pages are resolvable.
|
||||
*/
|
||||
installVanguardRuntime({ vue, inertia, ui });
|
||||
|
||||
void createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
|
||||
resolve: (name: string): DefineComponent | Promise<DefineComponent> => {
|
||||
if (name.includes('::')) {
|
||||
return resolvePage(name);
|
||||
}
|
||||
|
||||
return resolvePageComponent(
|
||||
`./pages/${name}.vue`,
|
||||
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
|
||||
);
|
||||
},
|
||||
|
||||
setup({ el, App, props, plugin }) {
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(ZiggyVue)
|
||||
.mount(el);
|
||||
},
|
||||
|
||||
progress: {
|
||||
color: '#179970',
|
||||
},
|
||||
});
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from '@simplewebauthn/browser'
|
||||
|
||||
window.axios = axios;
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
|
||||
window.startAuthentication = startAuthentication;
|
||||
window.startRegistration = startRegistration;
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useSettings } from '@/composables/useVanguard';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'default' | 'no-text';
|
||||
height?: number | string;
|
||||
}>(),
|
||||
{ variant: 'default', height: 50 },
|
||||
);
|
||||
|
||||
const { setting } = useSettings();
|
||||
|
||||
const src = computed(() =>
|
||||
props.variant === 'no-text' ? '/assets/img/logo-no-text.png' : '/assets/img/logo.png',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="src" :alt="String(setting('app_name') ?? 'Vanguard')" :height="height" :style="{height: `${height}px`}" />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
enableUrl: string;
|
||||
disableUrl: string;
|
||||
}>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
const processing = ref(false);
|
||||
|
||||
function toggle() {
|
||||
processing.value = true;
|
||||
|
||||
router.post(
|
||||
props.enabled ? props.disableUrl : props.enableUrl,
|
||||
{},
|
||||
{ preserveScroll: true, onFinish: () => (processing.value = false) },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h5 class="mb-1 text-base font-medium text-slate-800">{{ title }}</h5>
|
||||
|
||||
<small class="mb-4 block text-slate-500">{{ description }}</small>
|
||||
|
||||
<slot name="notice">
|
||||
<Button
|
||||
:label="enabled ? __('Disable') : __('Enable')"
|
||||
:severity="enabled ? 'danger' : 'primary'"
|
||||
:loading="processing"
|
||||
@click="toggle"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
type ChartConfiguration,
|
||||
} from 'chart.js';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
labels: string[];
|
||||
values: number[];
|
||||
/** Series label, shown in the tooltip title area. */
|
||||
label?: string;
|
||||
/** Builds the tooltip text for a value, e.g. "3 actions". */
|
||||
formatValue?: (value: number) => string;
|
||||
height?: number;
|
||||
}>(),
|
||||
{ height: 365 },
|
||||
);
|
||||
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
|
||||
const BRAND = '#179970';
|
||||
|
||||
function config(): ChartConfiguration {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: props.label ?? '',
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: BRAND,
|
||||
pointBackgroundColor: BRAND,
|
||||
data: props.values,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#f6f6f6' },
|
||||
border: { display: false },
|
||||
// Registrations and activity counts are whole numbers.
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
titleMarginBottom: 15,
|
||||
callbacks: {
|
||||
label: (item) =>
|
||||
props.formatValue
|
||||
? ` ${props.formatValue(Number(item.parsed.y))}`
|
||||
: ` ${item.parsed.y}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canvas.value) {
|
||||
chart = new Chart(canvas.value, config());
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.labels, props.values],
|
||||
() => {
|
||||
if (!chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
chart.data.labels = props.labels;
|
||||
chart.data.datasets[0].data = props.values;
|
||||
chart.update();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chart?.destroy();
|
||||
chart = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="{ height: `${height}px` }">
|
||||
<canvas ref="canvas" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import Menu from '@/components/ui/Menu.vue';
|
||||
import MenuItem from '@/components/ui/MenuItem.vue';
|
||||
import type { SharedProps } from '@/types';
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
const current = computed(() => page.props.locale);
|
||||
|
||||
const currentFlag = computed(
|
||||
() => page.props.locales.find((locale) => locale.code === current.value)?.flag ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Locale switching is handled server side by App\Http\Middleware\SetLocale,
|
||||
* which reads ?lang= and stores the choice in the session. A full page visit
|
||||
* keeps that behaviour intact and re-fetches the translation catalog.
|
||||
*/
|
||||
function switchTo(code: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('lang', code);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<Menu>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-slate-500 uppercase hover:bg-slate-100"
|
||||
>
|
||||
<img v-if="currentFlag" :src="currentFlag" :alt="current" width="24" />
|
||||
<span>{{ current }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<MenuItem
|
||||
v-for="locale in page.props.locales"
|
||||
:key="locale.code"
|
||||
@select="switchTo(locale.code)"
|
||||
>
|
||||
<img v-if="locale.flag" :src="locale.flag" :alt="locale.code" width="24" />
|
||||
<span class="uppercase">{{ locale.code }}</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useSlot } from '@/composables/useVanguard';
|
||||
import { resolveSlot } from '@/vanguard';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Slot position name, e.g. "navbar:items". */
|
||||
name: string;
|
||||
/**
|
||||
* Payloads for page scoped positions (e.g. "user:show"), which the owning
|
||||
* controller resolves and passes as a page prop. Global positions such as
|
||||
* "navbar:items" come from shared props and need nothing here.
|
||||
*/
|
||||
payloads?: Array<{ component: string; props: Record<string, unknown> }>;
|
||||
}>();
|
||||
|
||||
const sharedPayloads = useSlot(props.name);
|
||||
|
||||
const entries = computed(() => {
|
||||
const payloads = props.payloads ?? sharedPayloads.value;
|
||||
|
||||
return payloads
|
||||
.map((payload) => ({
|
||||
component: resolveSlot(payload.component),
|
||||
props: payload.props,
|
||||
key: payload.component,
|
||||
}))
|
||||
.filter((entry) => entry.component !== undefined);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="entry.component"
|
||||
v-for="entry in entries"
|
||||
:key="entry.key"
|
||||
v-bind="entry.props"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{ sitekey: string }>();
|
||||
const emit = defineEmits<{ verified: [token: string] }>();
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const widgetId = ref<number | null>(null);
|
||||
|
||||
const SCRIPT_ID = 'recaptcha-api';
|
||||
const CALLBACK = '__vanguardRecaptchaLoaded';
|
||||
|
||||
interface Grecaptcha {
|
||||
render(el: HTMLElement, options: Record<string, unknown>): number;
|
||||
reset(id?: number): void;
|
||||
}
|
||||
|
||||
function grecaptcha(): Grecaptcha | undefined {
|
||||
return (window as unknown as { grecaptcha?: Grecaptcha }).grecaptcha;
|
||||
}
|
||||
|
||||
function renderWidget() {
|
||||
const api = grecaptcha();
|
||||
|
||||
if (!api || !container.value || widgetId.value !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
widgetId.value = api.render(container.value, {
|
||||
sitekey: props.sitekey,
|
||||
callback: (token: string) => emit('verified', token),
|
||||
'expired-callback': () => emit('verified', ''),
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (grecaptcha()) {
|
||||
renderWidget();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
(window as unknown as Record<string, unknown>)[CALLBACK] = renderWidget;
|
||||
|
||||
if (!document.getElementById(SCRIPT_ID)) {
|
||||
const script = document.createElement('script');
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = `https://www.google.com/recaptcha/api.js?onload=${CALLBACK}&render=explicit`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (widgetId.value !== null) {
|
||||
grecaptcha()?.reset(widgetId.value);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
reset: () => {
|
||||
if (widgetId.value !== null) {
|
||||
grecaptcha()?.reset(widgetId.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<h6 v-if="title" class="border-b border-slate-100 px-6 py-4 font-medium">{{ title }}</h6>
|
||||
|
||||
<div class="p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { SharedProps, SidebarItem } from '@/types';
|
||||
|
||||
const props = defineProps<{ item: SidebarItem; nested?: boolean }>();
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
/**
|
||||
* Replaces Blade's Request::is($item->getActivePath()), which matched the
|
||||
* current path against a pattern that may contain "*" wildcards.
|
||||
*/
|
||||
function matches(pattern: string | null | undefined): boolean {
|
||||
if (!pattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = new URL(page.url, window.location.origin).pathname.replace(/^\/+/, '');
|
||||
const regex = new RegExp(
|
||||
`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`,
|
||||
);
|
||||
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
const isDropdown = computed(() => props.item.children.length > 0);
|
||||
const isActive = computed(() => matches(props.item.activePath));
|
||||
const isExpandedByRoute = computed(
|
||||
() => props.item.expandedPath?.some((pattern) => matches(pattern)) ?? false,
|
||||
);
|
||||
|
||||
const manuallyExpanded = ref<boolean | null>(null);
|
||||
const expanded = computed(() => manuallyExpanded.value ?? isExpandedByRoute.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<component
|
||||
:is="isDropdown ? 'button' : Link"
|
||||
v-bind="isDropdown ? { type: 'button' } : { href: item.href ?? '#' }"
|
||||
class="flex w-full cursor-pointer items-center gap-3 px-6 py-3 text-left text-slate-400 transition hover:text-slate-200"
|
||||
:class="[
|
||||
isActive ? 'bg-sidebar-active text-slate-200' : '',
|
||||
nested ? 'pl-14 text-sm' : '',
|
||||
]"
|
||||
:aria-expanded="isDropdown ? expanded : undefined"
|
||||
@click="isDropdown ? (manuallyExpanded = !expanded) : undefined"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="w-5 text-center text-base" />
|
||||
<span class="flex-1">{{ item.title }}</span>
|
||||
<i
|
||||
v-if="isDropdown"
|
||||
class="fas fa-chevron-down text-xs transition-transform"
|
||||
:class="expanded ? 'rotate-180' : ''"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<ul v-if="isDropdown" v-show="expanded" class="list-none">
|
||||
<SidebarItem
|
||||
v-for="child in item.children"
|
||||
:key="child.title"
|
||||
:item="child"
|
||||
nested
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
providers: string[] | null;
|
||||
to?: string | null;
|
||||
}>();
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
facebook: 'fab fa-facebook',
|
||||
twitter: 'fab fa-twitter',
|
||||
google: 'fab fa-google-plus-square',
|
||||
};
|
||||
|
||||
const TONES: Record<string, string> = {
|
||||
facebook: 'text-[#1877f2] hover:text-[#0d5fc4]',
|
||||
twitter: 'text-[#1da1f2] hover:text-[#0d8ddb]',
|
||||
google: 'text-[#db4437] hover:text-[#b7362c]',
|
||||
};
|
||||
|
||||
const available = computed(() =>
|
||||
(props.providers ?? []).filter((provider) => provider in ICONS),
|
||||
);
|
||||
|
||||
function url(provider: string): string {
|
||||
const suffix = props.to ? `?to=${encodeURIComponent(props.to)}` : '';
|
||||
|
||||
return `/auth/${provider}/login${suffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="available.length" class="flex items-center justify-center gap-8 pt-2 pb-4">
|
||||
<a
|
||||
v-for="provider in available"
|
||||
:key="provider"
|
||||
:href="url(provider)"
|
||||
:class="TONES[provider]"
|
||||
:aria-label="provider"
|
||||
>
|
||||
<i :class="ICONS[provider]" class="text-3xl" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Terms of Service copy shown in the registration dialog.
|
||||
*
|
||||
* This is the Vue equivalent of the old resources/views/auth/tos.blade.php and,
|
||||
* like it, ships as placeholder copy for you to replace.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-sm space-y-4 text-slate-700">
|
||||
<h4 class="text-base font-semibold">1. Terms</h4>
|
||||
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec quis lacus porttitor,
|
||||
dignissim nibh sit amet, fermentum felis. Vestibulum ante ipsum primis in faucibus orci
|
||||
luctus et ultrices posuere cubilia Curae; In ultricies consectetur viverra. Nullam velit
|
||||
neque, placerat condimentum tempus tincidunt, placerat eu lectus. Nam molestie porta
|
||||
purus, et pretium risus vehicula in. Cras sem ipsum, varius sagittis rhoncus nec, dictum
|
||||
maximus diam. Duis ac laoreet est. In turpis velit, placerat eget nisi vitae, dignissim
|
||||
tristique nisl. Curabitur sollicitudin, nunc ut viverra interdum, lacus...
|
||||
</p>
|
||||
|
||||
<h4 class="text-base font-semibold">2. Use License</h4>
|
||||
|
||||
<ol class="list-[lower-alpha] space-y-2 pl-6">
|
||||
<li>
|
||||
Aenean vehicula erat eu nisi scelerisque, a mattis purus blandit. Curabitur congue
|
||||
ollis nisl malesuada egestas. Lorem ipsum dolor sit amet, consectetur adipiscing
|
||||
elit:
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p>...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import LocaleDropdown from '@/components/LocaleDropdown.vue';
|
||||
import PluginSlot from '@/components/PluginSlot.vue';
|
||||
import Menu from '@/components/ui/Menu.vue';
|
||||
import MenuItem from '@/components/ui/MenuItem.vue';
|
||||
import MenuSeparator from '@/components/ui/MenuSeparator.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
import type { SharedProps } from '@/types';
|
||||
|
||||
defineProps<{ heading?: string }>();
|
||||
defineEmits<{ toggleSidebar: [] }>();
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
const { __ } = useTranslations();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="border-primary-600 fixed top-0 right-0 left-0 z-20 flex h-(--spacing-navbar) items-stretch border-t-2 bg-white shadow-sm">
|
||||
<!--
|
||||
The block is only as wide as the sidebar it sits above, and the
|
||||
sidebar is off canvas below md. Holding 250px there would leave a
|
||||
390px viewport almost nothing for the heading and the controls.
|
||||
-->
|
||||
<div class="flex shrink-0 items-center justify-center border-r border-slate-100 px-4 md:w-(--spacing-sidebar) md:px-0">
|
||||
<Link :href="route('dashboard')">
|
||||
<AppLogo :height="35" variant="no-text" class="md:hidden" />
|
||||
<AppLogo :height="35" class="hidden md:block" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center gap-4 px-4">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer text-slate-400 hover:text-slate-600 md:hidden"
|
||||
:aria-label="__('Toggle navigation')"
|
||||
@click="$emit('toggleSidebar')"
|
||||
>
|
||||
<i class="fas fa-bars" />
|
||||
</button>
|
||||
|
||||
<h4
|
||||
v-if="heading"
|
||||
class="truncate text-lg font-normal text-slate-700 md:mr-4 md:border-r md:border-slate-200 md:pr-5"
|
||||
>
|
||||
{{ heading }}
|
||||
</h4>
|
||||
|
||||
<slot name="breadcrumbs" />
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<a
|
||||
v-if="page.props.auth.impersonating"
|
||||
:href="route('impersonate.leave')"
|
||||
class="flex items-center gap-2 px-3 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<i class="fas fa-user-secret" />
|
||||
<span class="hidden lg:inline">{{ __('Stop Impersonating') }}</span>
|
||||
</a>
|
||||
|
||||
<PluginSlot name="navbar:items" />
|
||||
|
||||
<LocaleDropdown />
|
||||
|
||||
<Menu v-if="page.props.auth.user">
|
||||
<template #trigger>
|
||||
<button type="button" class="cursor-pointer">
|
||||
<img
|
||||
:src="page.props.auth.user.avatar"
|
||||
width="44"
|
||||
height="44"
|
||||
:alt="page.props.auth.user.nameOrEmail"
|
||||
class="rounded-full border border-slate-200 object-cover"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<MenuItem :href="route('profile')" icon="fas fa-user">
|
||||
{{ __('My Profile') }}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
v-if="page.props.config.databaseSessions"
|
||||
:href="route('profile.sessions')"
|
||||
icon="fas fa-list"
|
||||
>
|
||||
{{ __('Active Sessions') }}
|
||||
</MenuItem>
|
||||
|
||||
<MenuSeparator />
|
||||
|
||||
<MenuItem :href="route('auth.logout')" icon="fas fa-sign-out-alt">
|
||||
{{ __('Logout') }}
|
||||
</MenuItem>
|
||||
|
||||
<PluginSlot name="navbar:dropdown" />
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import SidebarItem from '@/components/SidebarItem.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
import type { SharedProps } from '@/types';
|
||||
|
||||
defineProps<{ open: boolean }>();
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
const { __ } = useTranslations();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="bg-sidebar fixed top-(--spacing-navbar) bottom-0 left-0 z-10 h-full w-(--spacing-sidebar) min-w-(--spacing-sidebar) overflow-y-auto pb-8 shadow-lg transition-all duration-300"
|
||||
:class="open ? 'ml-0' : '-ml-(--spacing-sidebar) md:ml-0'"
|
||||
>
|
||||
<div v-if="page.props.auth.user" class="px-4 pt-8 pb-4 text-center">
|
||||
<img
|
||||
:src="page.props.auth.user.avatar"
|
||||
width="90"
|
||||
height="90"
|
||||
:alt="page.props.auth.user.nameOrEmail"
|
||||
class="mx-auto rounded-full border-2 border-slate-600 object-cover"
|
||||
/>
|
||||
|
||||
<h5 class="my-3">
|
||||
<Link :href="route('profile')" class="text-slate-400 hover:text-primary-600">
|
||||
{{ page.props.auth.user.nameOrEmail }}
|
||||
</Link>
|
||||
</h5>
|
||||
|
||||
<ul class="flex justify-center gap-4">
|
||||
<li>
|
||||
<Link
|
||||
:href="route('profile')"
|
||||
:title="__('My Profile')"
|
||||
class="text-slate-400 hover:text-primary-600"
|
||||
>
|
||||
<i class="fas fa-cog" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
:href="route('auth.logout')"
|
||||
:title="__('Logout')"
|
||||
class="text-slate-400 hover:text-primary-600"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul class="list-none">
|
||||
<SidebarItem
|
||||
v-for="item in page.props.vanguard.sidebar"
|
||||
:key="item.title"
|
||||
:item="item"
|
||||
/>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Switch from '@/components/ui/Switch.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Stored as 1/0 to match the values the Blade forms submitted. */
|
||||
modelValue: number | boolean;
|
||||
label: string;
|
||||
description?: string;
|
||||
inputId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
|
||||
|
||||
const checked = computed({
|
||||
get: () => Boolean(Number(props.modelValue)),
|
||||
set: (value: boolean) => emit('update:modelValue', value ? 1 : 0),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch v-model="checked" :id="inputId" class="mt-1" />
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label :for="inputId" class="cursor-pointer text-slate-700">{{ label }}</label>
|
||||
<small v-if="description" class="text-slate-500">{{ description }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Severity = 'primary' | 'secondary' | 'danger' | 'success' | 'info';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Text content. The default slot wins when both are given. */
|
||||
label?: string;
|
||||
/** Icon class, e.g. `fas fa-plus`. Rendered before the label. */
|
||||
icon?: string;
|
||||
severity?: Severity;
|
||||
size?: 'small' | 'normal' | 'large';
|
||||
/** Shows a spinner and blocks interaction. */
|
||||
loading?: boolean;
|
||||
/** Pill shape. Used for the icon-only action buttons in list views. */
|
||||
rounded?: boolean;
|
||||
/** Borderless, transparent until hovered. */
|
||||
text?: boolean;
|
||||
outlined?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
label: undefined,
|
||||
icon: undefined,
|
||||
severity: 'primary',
|
||||
size: 'normal',
|
||||
type: 'button',
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Every variant resolves to plain Tailwind utilities so an application built on
|
||||
* Vanguard can restyle a button by editing this file, with no theme layer or
|
||||
* specificity to work around.
|
||||
*/
|
||||
const solid: Record<Severity, string> = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:outline-primary-600',
|
||||
secondary: 'bg-slate-200 text-slate-700 hover:bg-slate-300 focus-visible:outline-slate-400',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600',
|
||||
success: 'bg-green-600 text-white hover:bg-green-700 focus-visible:outline-green-600',
|
||||
info: 'bg-sky-600 text-white hover:bg-sky-700 focus-visible:outline-sky-600',
|
||||
};
|
||||
|
||||
const subtle: Record<Severity, string> = {
|
||||
primary: 'text-primary-600 hover:bg-primary-50 focus-visible:outline-primary-600',
|
||||
secondary: 'text-slate-400 hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-slate-400',
|
||||
danger: 'text-red-600 hover:bg-red-50 focus-visible:outline-red-600',
|
||||
success: 'text-green-600 hover:bg-green-50 focus-visible:outline-green-600',
|
||||
info: 'text-sky-600 hover:bg-sky-50 focus-visible:outline-sky-600',
|
||||
};
|
||||
|
||||
const outline: Record<Severity, string> = {
|
||||
primary: 'border border-primary-600 text-primary-600 hover:bg-primary-50',
|
||||
secondary: 'border border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
danger: 'border border-red-600 text-red-600 hover:bg-red-50',
|
||||
success: 'border border-green-600 text-green-600 hover:bg-green-50',
|
||||
info: 'border border-sky-600 text-sky-600 hover:bg-sky-50',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
small: 'px-2.5 py-1 text-xs',
|
||||
normal: 'px-4 py-2',
|
||||
large: 'px-5 py-2.5 text-base',
|
||||
};
|
||||
|
||||
/** Icon-only buttons are squared off so the pill variant renders as a circle. */
|
||||
const iconOnly = computed(() => Boolean(props.icon) && !props.label);
|
||||
|
||||
const iconSizes = {
|
||||
small: 'h-7 w-7',
|
||||
normal: 'h-9 w-9',
|
||||
large: 'h-11 w-11',
|
||||
};
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex cursor-pointer items-center justify-center gap-2 font-medium transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-60',
|
||||
props.rounded ? 'rounded-full' : 'rounded-md',
|
||||
iconOnly.value ? iconSizes[props.size] : sizes[props.size],
|
||||
props.text ? subtle[props.severity] : props.outlined ? outline[props.severity] : solid[props.severity],
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="classes" :disabled="disabled || loading">
|
||||
<i v-if="loading" class="fas fa-circle-notch animate-spin" aria-hidden="true" />
|
||||
<i v-else-if="icon" :class="icon" aria-hidden="true" />
|
||||
|
||||
<slot>{{ label }}</slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* When set, the checkbox toggles this value inside an array model, which is
|
||||
* how the permission matrix binds a column of boxes to one role.
|
||||
* Without it the model is a plain boolean.
|
||||
*/
|
||||
value?: string | number;
|
||||
invalid?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<boolean | Array<string | number> | null>();
|
||||
|
||||
const checked = computed(() => {
|
||||
if (props.value === undefined) {
|
||||
return Boolean(model.value);
|
||||
}
|
||||
|
||||
return Array.isArray(model.value) && model.value.includes(props.value);
|
||||
});
|
||||
|
||||
function toggle(state: boolean | 'indeterminate'): void {
|
||||
if (props.value === undefined) {
|
||||
model.value = state === true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const current = Array.isArray(model.value) ? model.value : [];
|
||||
|
||||
model.value = state === true
|
||||
? [...current, props.value]
|
||||
: current.filter((entry) => entry !== props.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
:id="id"
|
||||
:model-value="checked"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="[
|
||||
checked ? 'border-primary-600 bg-primary-600 text-white' : 'bg-white',
|
||||
invalid ? 'border-red-500 focus-visible:outline-red-500' : 'border-slate-300 focus-visible:outline-primary-600',
|
||||
]"
|
||||
@update:model-value="toggle"
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
<i class="fas fa-check text-[0.65rem]" aria-hidden="true" />
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
} from 'reka-ui';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import { useConfirm } from '@/composables/useConfirm';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
const { request, open, close } = useConfirm();
|
||||
const { __ } = useTranslations();
|
||||
|
||||
function accept(): void {
|
||||
request.value?.accept();
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-model:open="open">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="fixed inset-0 z-40 bg-black/50" />
|
||||
|
||||
<AlertDialogContent
|
||||
class="fixed top-1/2 left-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-xl focus:outline-none"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i
|
||||
class="fas fa-exclamation-triangle mt-1 text-xl"
|
||||
:class="request?.severity === 'danger' ? 'text-red-500' : 'text-primary-600'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="flex-1">
|
||||
<AlertDialogTitle class="text-lg font-medium text-slate-800">
|
||||
{{ request?.header || __('Please Confirm') }}
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription class="mt-1 text-slate-600">
|
||||
{{ request?.message }}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button :label="request?.cancelLabel || __('Cancel')" severity="secondary" outlined />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction as-child>
|
||||
<Button
|
||||
:label="request?.confirmLabel || __('Yes, delete it!')"
|
||||
:severity="request?.severity ?? 'danger'"
|
||||
@click="accept"
|
||||
/>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
withDefaults(defineProps<{ header?: string; width?: string }>(), { width: '40rem' });
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const { __ } = useTranslations();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-40 bg-black/50" />
|
||||
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg bg-white shadow-xl focus:outline-none"
|
||||
:style="{ maxWidth: width }"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<DialogTitle class="text-lg font-medium text-slate-800">{{ header }}</DialogTitle>
|
||||
|
||||
<DialogClose
|
||||
class="focus-visible:outline-primary-600 cursor-pointer rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
:aria-label="__('Close')"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto px-6 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-slate-100 px-6 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Renders the error border. Pair it with a message below the field. */
|
||||
invalid?: boolean;
|
||||
type?: string;
|
||||
}>(),
|
||||
{ type: 'text' },
|
||||
);
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
:type="type"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="bg-field block rounded-md border px-3 py-2 text-slate-700 shadow-xs transition-colors placeholder:text-slate-400 focus:outline-none disabled:bg-slate-200 disabled:text-slate-400"
|
||||
:class="
|
||||
invalid
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
||||
: 'border-slate-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-100'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuContent, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui';
|
||||
|
||||
/**
|
||||
* Replaces the PrimeVue popup Menu, whose imperative `menu.toggle($event)` call
|
||||
* required a template ref on every caller. Reka wires the trigger up itself, so
|
||||
* a caller only supplies the trigger element and the items.
|
||||
*/
|
||||
withDefaults(defineProps<{ align?: 'start' | 'center' | 'end' }>(), { align: 'end' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="trigger" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align="align"
|
||||
:side-offset="6"
|
||||
class="z-50 min-w-48 overflow-hidden rounded-md border border-slate-200 bg-white py-1 shadow-lg focus:outline-none"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuItem } from 'reka-ui';
|
||||
|
||||
defineProps<{
|
||||
/** Renders the item as a link. Omit it for items driven by `@select`. */
|
||||
href?: string;
|
||||
icon?: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{ select: [] }>();
|
||||
|
||||
const itemClass =
|
||||
'flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-left text-slate-700 outline-none data-highlighted:bg-slate-50';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem v-if="href" as-child @select="$emit('select')">
|
||||
<a :href="href" :class="itemClass">
|
||||
<i v-if="icon" :class="icon" class="w-4 text-slate-400" aria-hidden="true" />
|
||||
<slot />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-else :class="itemClass" @select="$emit('select')">
|
||||
<i v-if="icon" :class="icon" class="w-4 text-slate-400" aria-hidden="true" />
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownMenuSeparator } from 'reka-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-slate-100" />
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Severity = 'success' | 'info' | 'warn' | 'error';
|
||||
|
||||
const props = withDefaults(defineProps<{ severity?: Severity }>(), { severity: 'info' });
|
||||
|
||||
const styles: Record<Severity, { container: string; icon: string }> = {
|
||||
success: { container: 'border-green-200 bg-green-50 text-green-800', icon: 'fas fa-check-circle' },
|
||||
info: { container: 'border-sky-200 bg-sky-50 text-sky-800', icon: 'fas fa-info-circle' },
|
||||
warn: { container: 'border-amber-200 bg-amber-50 text-amber-800', icon: 'fas fa-exclamation-triangle' },
|
||||
error: { container: 'border-red-200 bg-red-50 text-red-800', icon: 'fas fa-times-circle' },
|
||||
};
|
||||
|
||||
const style = computed(() => styles[props.severity]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md border px-4 py-3"
|
||||
:class="style.container"
|
||||
:role="severity === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
<i :class="style.icon" class="mt-1 shrink-0" aria-hidden="true" />
|
||||
<div><slot /></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
/** One entry of Laravel's paginator `links` array. */
|
||||
export interface PaginatorLink {
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
links: PaginatorLink[];
|
||||
/** Total results, used for the "showing x of y" line. */
|
||||
total?: number;
|
||||
}>();
|
||||
|
||||
/** Laravel emits the prev/next labels as HTML entities. */
|
||||
const cleaned = computed(() =>
|
||||
props.links.map((link) => ({
|
||||
...link,
|
||||
label: link.label.replace('«', '‹').replace('»', '›'),
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="links.length > 3" class="mt-4 flex flex-wrap justify-end gap-1">
|
||||
<component
|
||||
:is="link.url ? Link : 'span'"
|
||||
v-for="(link, index) in cleaned"
|
||||
:key="index"
|
||||
:href="link.url ?? undefined"
|
||||
preserve-scroll
|
||||
class="inline-flex min-w-9 items-center justify-center rounded-md border px-3 py-1.5"
|
||||
:class="[
|
||||
link.active
|
||||
? 'border-primary-600 bg-primary-600 text-white'
|
||||
: 'border-slate-200 bg-white text-slate-600',
|
||||
link.url ? 'hover:border-primary-600 hover:text-primary-600' : 'cursor-default opacity-50',
|
||||
]"
|
||||
>
|
||||
{{ link.label }}
|
||||
</component>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
invalid?: boolean;
|
||||
/** Renders the reveal toggle. */
|
||||
toggleable?: boolean;
|
||||
}>(),
|
||||
{ toggleable: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* The root is a positioning wrapper, so attributes must be forwarded to the
|
||||
* input itself. Without this, id, placeholder and autocomplete would land on
|
||||
* the wrapping div and the field's <label for> would associate with nothing.
|
||||
*/
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const model = defineModel<string>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
const revealed = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
v-model="model"
|
||||
:type="revealed ? 'text' : 'password'"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="bg-field block w-full rounded-md border py-2 pl-3 text-slate-700 shadow-xs transition-colors placeholder:text-slate-400 focus:outline-none"
|
||||
:class="[
|
||||
toggleable ? 'pr-10' : 'pr-3',
|
||||
invalid
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
||||
: 'border-slate-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-100',
|
||||
]"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="toggleable"
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center text-slate-400 hover:text-slate-600"
|
||||
:aria-label="revealed ? __('Hide password') : __('Show password')"
|
||||
:aria-pressed="revealed"
|
||||
@click="revealed = !revealed"
|
||||
>
|
||||
<i :class="revealed ? 'fas fa-eye-slash' : 'fas fa-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
options: SelectOption[];
|
||||
invalid?: boolean;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
|
||||
/** Attributes belong on the <select>, not on the positioning wrapper. */
|
||||
defineOptions({ inheritAttrs: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
A styled native <select> rather than a custom listbox. The country list
|
||||
runs to a few hundred entries, where native type-ahead and the platform
|
||||
picker on mobile beat anything we would hand-roll.
|
||||
-->
|
||||
<div class="relative">
|
||||
<select
|
||||
v-bind="$attrs"
|
||||
v-model="model"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="bg-field block w-full appearance-none rounded-md border py-2 pr-9 pl-3 text-slate-700 shadow-xs transition-colors focus:outline-none disabled:bg-slate-200 disabled:text-slate-400"
|
||||
:class="
|
||||
invalid
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
||||
: 'border-slate-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-100'
|
||||
"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
The centering lives on the wrapper, not the <i>. Font Awesome ships
|
||||
unlayered, so its `.fas { display: inline-block }` outranks a
|
||||
Tailwind `flex` utility in @layer utilities no matter the order.
|
||||
-->
|
||||
<span class="pointer-events-none absolute inset-y-0 right-3 flex items-center">
|
||||
<i class="fas fa-chevron-down text-xs text-slate-400" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui';
|
||||
|
||||
defineProps<{ id?: string; disabled?: boolean }>();
|
||||
|
||||
const model = defineModel<boolean>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:disabled="disabled"
|
||||
class="focus-visible:outline-primary-600 relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="model ? 'bg-primary-600' : 'bg-slate-300'"
|
||||
>
|
||||
<SwitchThumb
|
||||
class="block size-5 rounded-full bg-white shadow-sm transition-transform"
|
||||
:class="model ? 'translate-x-5.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui';
|
||||
|
||||
defineProps<{
|
||||
/** `{ value, label, icon? }` for each tab; panels are named slots keyed by value. */
|
||||
tabs: Array<{ value: string; label: string; icon?: string }>;
|
||||
}>();
|
||||
|
||||
const active = defineModel<string>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-model="active">
|
||||
<TabsList class="mb-4 flex gap-1 border-b border-slate-200">
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="data-[state=active]:border-primary-600 data-[state=active]:text-primary-600 -mb-px cursor-pointer border-b-2 border-transparent px-4 py-2 font-medium text-slate-500 transition-colors hover:text-slate-700 focus-visible:outline-none"
|
||||
>
|
||||
<i v-if="tab.icon" :class="tab.icon" class="mr-2" aria-hidden="true" />
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="focus-visible:outline-none"
|
||||
>
|
||||
<slot :name="tab.value" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ invalid?: boolean; rows?: number }>(), { rows: 3 });
|
||||
|
||||
const model = defineModel<string | null>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-model="model"
|
||||
:rows="rows"
|
||||
:aria-invalid="invalid || undefined"
|
||||
class="bg-field block rounded-md border px-3 py-2 text-slate-700 shadow-xs transition-colors placeholder:text-slate-400 focus:outline-none"
|
||||
:class="
|
||||
invalid
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-100'
|
||||
: 'border-slate-300 focus:border-primary-500 focus:ring-2 focus:ring-primary-100'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ToastClose, ToastProvider, ToastRoot, ToastTitle, ToastViewport } from 'reka-ui';
|
||||
import { useToast, type ToastSeverity } from '@/composables/useToast';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
const { messages, remove } = useToast();
|
||||
const { __ } = useTranslations();
|
||||
|
||||
const styles: Record<ToastSeverity, { container: string; icon: string }> = {
|
||||
success: { container: 'border-green-200 bg-green-50 text-green-800', icon: 'fas fa-check-circle' },
|
||||
info: { container: 'border-sky-200 bg-sky-50 text-sky-800', icon: 'fas fa-info-circle' },
|
||||
warn: { container: 'border-amber-200 bg-amber-50 text-amber-800', icon: 'fas fa-exclamation-triangle' },
|
||||
error: { container: 'border-red-200 bg-red-50 text-red-800', icon: 'fas fa-times-circle' },
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToastProvider>
|
||||
<ToastRoot
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:duration="message.life"
|
||||
class="flex items-start gap-2 rounded-md border px-4 py-3 shadow-md data-[state=closed]:opacity-0"
|
||||
:class="styles[message.severity].container"
|
||||
@update:open="(open: boolean) => !open && remove(message.id)"
|
||||
>
|
||||
<i :class="styles[message.severity].icon" class="mt-1 shrink-0" aria-hidden="true" />
|
||||
|
||||
<ToastTitle class="flex-1">{{ message.summary }}</ToastTitle>
|
||||
|
||||
<ToastClose class="cursor-pointer opacity-60 hover:opacity-100" :aria-label="__('Close')">
|
||||
<i class="fas fa-times" aria-hidden="true" />
|
||||
</ToastClose>
|
||||
</ToastRoot>
|
||||
|
||||
<ToastViewport class="fixed top-4 left-1/2 -translate-x-1/2 z-100 flex w-96 max-w-[calc(100vw-2rem)] flex-col gap-2" />
|
||||
</ToastProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TooltipArrow,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from 'reka-ui';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
text: string;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}>(),
|
||||
{ side: 'top' },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
The provider lives here rather than in a layout so a <Tooltip> works
|
||||
anywhere it is dropped. Reka throws when TooltipRoot cannot inject a
|
||||
provider context, and an application built on Vanguard should not have
|
||||
to know that.
|
||||
-->
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
:side="side"
|
||||
:side-offset="4"
|
||||
class="z-50 rounded bg-slate-800 px-2 py-1 text-xs text-white shadow-md"
|
||||
>
|
||||
{{ text }}
|
||||
<TooltipArrow class="fill-slate-800" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* The Vanguard UI kit.
|
||||
*
|
||||
* Thin, fully owned wrappers over Reka UI primitives. Reka supplies focus
|
||||
* management, keyboard interaction and ARIA wiring; everything visual is plain
|
||||
* Tailwind in these files, so an application built on Vanguard restyles a
|
||||
* component by editing it rather than fighting a theme layer.
|
||||
*
|
||||
* This barrel is also what plugin bundles receive as `Vanguard.ui`, so they can
|
||||
* render Vanguard-styled controls without bundling their own copies.
|
||||
*/
|
||||
// Not a primitive, but plugin pages have to render inside the host chrome and
|
||||
// this barrel is the plugin facing surface.
|
||||
export { default as AppLayout } from '../../layouts/AppLayout.vue';
|
||||
export { default as Button } from './Button.vue';
|
||||
export { default as Checkbox } from './Checkbox.vue';
|
||||
// Re-exported from outside ui/ so plugin bundles can draw charts without
|
||||
// bundling their own copy of Chart.js.
|
||||
export { default as LineChart } from '../LineChart.vue';
|
||||
export { default as ConfirmHost } from './ConfirmHost.vue';
|
||||
export { default as Dialog } from './Dialog.vue';
|
||||
export { default as Input } from './Input.vue';
|
||||
export { default as Menu } from './Menu.vue';
|
||||
export { default as MenuItem } from './MenuItem.vue';
|
||||
export { default as MenuSeparator } from './MenuSeparator.vue';
|
||||
export { default as Message } from './Message.vue';
|
||||
export { default as Pagination } from './Pagination.vue';
|
||||
export { default as PasswordInput } from './PasswordInput.vue';
|
||||
export { default as Select } from './Select.vue';
|
||||
export { default as Switch } from './Switch.vue';
|
||||
export { default as Tabs } from './Tabs.vue';
|
||||
export { default as Textarea } from './Textarea.vue';
|
||||
export { default as ToastHost } from './ToastHost.vue';
|
||||
export { default as Tooltip } from './Tooltip.vue';
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Cropper } from 'vue-advanced-cropper';
|
||||
import 'vue-advanced-cropper/dist/style.css';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import Dialog from '@/components/ui/Dialog.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
const props = defineProps<{
|
||||
avatar: string;
|
||||
nameOrEmail: string;
|
||||
/** Multipart endpoint for a cropped upload. */
|
||||
uploadUrl: string;
|
||||
/** Endpoint that takes a `url` for Gravatar, socials and the placeholder. */
|
||||
externalUrl: string;
|
||||
sources: Array<{ label: string; url: string; external: boolean }>;
|
||||
}>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
|
||||
const picking = ref(false);
|
||||
const processing = ref(false);
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput');
|
||||
const cropper = useTemplateRef<InstanceType<typeof Cropper>>('cropper');
|
||||
|
||||
/** Object URL of the file being cropped; null when not cropping. */
|
||||
const editing = ref<string | null>(null);
|
||||
let pendingFile: File | null = null;
|
||||
|
||||
function chooseExternal(url: string): void {
|
||||
picking.value = false;
|
||||
processing.value = true;
|
||||
|
||||
router.post(
|
||||
props.externalUrl,
|
||||
{ url },
|
||||
{ preserveScroll: true, onFinish: () => (processing.value = false) },
|
||||
);
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event): void {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingFile = file;
|
||||
editing.value = URL.createObjectURL(file);
|
||||
picking.value = false;
|
||||
}
|
||||
|
||||
function cancelCrop(): void {
|
||||
if (editing.value) {
|
||||
URL.revokeObjectURL(editing.value);
|
||||
}
|
||||
|
||||
editing.value = null;
|
||||
pendingFile = null;
|
||||
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The server crops a square of side `x2 - x1` starting at (x1, y1), which is
|
||||
* why the stencil is locked to 1:1.
|
||||
*/
|
||||
function saveCrop(): void {
|
||||
const coordinates = cropper.value?.getResult()?.coordinates;
|
||||
|
||||
if (!pendingFile || !coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
processing.value = true;
|
||||
|
||||
router.post(
|
||||
props.uploadUrl,
|
||||
{
|
||||
avatar: pendingFile,
|
||||
points: {
|
||||
x1: Math.round(coordinates.left),
|
||||
y1: Math.round(coordinates.top),
|
||||
x2: Math.round(coordinates.left + coordinates.width),
|
||||
y2: Math.round(coordinates.top + coordinates.height),
|
||||
},
|
||||
},
|
||||
{
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
onSuccess: () => cancelCrop(),
|
||||
onFinish: () => (processing.value = false),
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div v-if="editing" class="space-y-4">
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
:src="editing"
|
||||
class="max-h-80 rounded"
|
||||
:stencil-props="{ aspectRatio: 1 }"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
:label="__('Cancel')"
|
||||
icon="fa fa-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="cancelCrop"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
icon="fa fa-check"
|
||||
severity="success"
|
||||
:loading="processing"
|
||||
@click="saveCrop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center">
|
||||
<img
|
||||
:src="avatar"
|
||||
width="150"
|
||||
height="150"
|
||||
:alt="nameOrEmail"
|
||||
class="mx-auto mb-4 size-[150px] rounded-full border border-slate-200 object-cover"
|
||||
/>
|
||||
|
||||
<h5 class="text-slate-500">{{ nameOrEmail }}</h5>
|
||||
|
||||
<Button
|
||||
:label="__('Change Photo')"
|
||||
icon="fa fa-camera"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="mt-6 w-full"
|
||||
:loading="processing"
|
||||
@click="picking = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="picking" :header="__('Change Photo')" width="34rem">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="hover:border-primary-600 flex cursor-pointer flex-col items-center gap-2 rounded-lg border border-transparent p-3 hover:bg-slate-50"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<span
|
||||
class="flex size-[100px] items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-slate-400"
|
||||
>
|
||||
<i class="fa fa-upload text-2xl" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ __('Upload Photo') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.label"
|
||||
type="button"
|
||||
class="hover:border-primary-600 flex cursor-pointer flex-col items-center gap-2 rounded-lg border border-transparent p-3 hover:bg-slate-50"
|
||||
@click="chooseExternal(source.url)"
|
||||
>
|
||||
<img
|
||||
:src="source.url"
|
||||
:alt="source.label"
|
||||
class="size-[100px] rounded-full border border-slate-200 object-cover"
|
||||
/>
|
||||
<span>{{ source.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import Checkbox from '@/components/ui/Checkbox.vue';
|
||||
import Input from '@/components/ui/Input.vue';
|
||||
import PasswordInput from '@/components/ui/PasswordInput.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
import type { LoginDetailsFormData } from '@/types';
|
||||
|
||||
const props = defineProps<{
|
||||
errors: Record<string, string>;
|
||||
/** Editing an existing user, which makes the password fields optional. */
|
||||
edit?: boolean;
|
||||
/** The admin-only "force password change" toggle. */
|
||||
showForcePasswordChange?: boolean;
|
||||
submitLabel?: string;
|
||||
processing?: boolean;
|
||||
}>();
|
||||
|
||||
const form = defineModel<LoginDetailsFormData>({ required: true });
|
||||
|
||||
const { __ } = useTranslations();
|
||||
|
||||
/**
|
||||
* Declared here rather than inline so the string handed to __() matches the
|
||||
* language file key exactly, apostrophe included.
|
||||
*/
|
||||
const optionalHint = __("Leave field blank if you don't want to change it");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-slate-700">{{ __('Email') }}</label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="w-full"
|
||||
:placeholder="__('Email')"
|
||||
:invalid="Boolean(errors.email)"
|
||||
/>
|
||||
<small v-if="errors.email" class="text-red-600">{{ errors.email }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username" class="mb-1 block text-slate-700">{{ __('Username') }}</label>
|
||||
<Input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
class="w-full"
|
||||
:placeholder="`(${__('optional')})`"
|
||||
:invalid="Boolean(errors.username)"
|
||||
/>
|
||||
<small v-if="errors.username" class="text-red-600">{{ errors.username }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-slate-700">
|
||||
{{ edit ? __('New Password') : __('Password') }}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="w-full"
|
||||
:placeholder="edit ? optionalHint : undefined"
|
||||
:invalid="Boolean(errors.password)"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small v-if="errors.password" class="text-red-600">{{ errors.password }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirmation" class="mb-1 block text-slate-700">
|
||||
{{ edit ? __('Confirm New Password') : __('Confirm Password') }}
|
||||
</label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
class="w-full"
|
||||
:placeholder="edit ? optionalHint : undefined"
|
||||
:invalid="Boolean(errors.password_confirmation)"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<small v-if="errors.password_confirmation" class="text-red-600">
|
||||
{{ errors.password_confirmation }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-if="showForcePasswordChange" class="flex items-center gap-2">
|
||||
<Checkbox id="force_password_change" v-model="form.force_password_change" />
|
||||
<label for="force_password_change" class="cursor-pointer text-slate-600">
|
||||
{{ __('Force Password Change on next login') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="submitLabel">
|
||||
<Button type="submit" icon="fa fa-refresh" :label="submitLabel" :loading="processing" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
/** Mirrors UserPresenter::labelClass(). */
|
||||
variant: string;
|
||||
}>();
|
||||
|
||||
const variants: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
warning: 'bg-amber-100 text-amber-800',
|
||||
};
|
||||
|
||||
const classes = computed(() => variants[props.variant] ?? variants.warning);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-block rounded-full px-3 py-1 text-xs font-semibold" :class="classes">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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';
|
||||
|
||||
const props = defineProps<{
|
||||
userId: number;
|
||||
twoFactor: {
|
||||
enabled: boolean;
|
||||
needsVerification: boolean;
|
||||
action: 'enable' | 'disable' | 'verify';
|
||||
qrCode: string | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
|
||||
const form = useForm({ user: props.userId, code: '' });
|
||||
|
||||
function submit(): void {
|
||||
form.post(route(`2fa.${props.twoFactor.action}`), { preserveScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared here rather than inline so the strings handed to __() match the
|
||||
* language file keys exactly, apostrophes included.
|
||||
*/
|
||||
const copy = {
|
||||
intro: __('Please finish configuring Two-Factor authentication below.'),
|
||||
prompt: __(
|
||||
'When two factor authentication is enabled, you will be prompted for a secure, random token during authentication.',
|
||||
),
|
||||
retrieve: __("You may retrieve this token from your phone's Authenticator application."),
|
||||
scan: __(
|
||||
"To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.",
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="submit">
|
||||
<Button
|
||||
v-if="twoFactor.action === 'enable'"
|
||||
type="submit"
|
||||
:label="__('Enable')"
|
||||
:loading="form.processing"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-else-if="twoFactor.action === 'disable'"
|
||||
type="submit"
|
||||
icon="fa fa-times"
|
||||
:label="__('Disable')"
|
||||
severity="danger"
|
||||
:loading="form.processing"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-slate-800">{{ copy.intro }}</h3>
|
||||
|
||||
<p class="font-semibold text-slate-600">{{ copy.prompt }} {{ copy.retrieve }}</p>
|
||||
|
||||
<p class="text-slate-600">{{ copy.scan }}</p>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- server generated SVG -->
|
||||
<div class="flex justify-center py-4" v-html="twoFactor.qrCode" />
|
||||
|
||||
<div>
|
||||
<label for="code" class="sr-only">{{ __('Code') }}</label>
|
||||
<Input
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
class="w-full"
|
||||
:placeholder="__('Code')"
|
||||
:invalid="Boolean(form.errors.code)"
|
||||
/>
|
||||
<small v-if="form.errors.code" class="text-red-600">{{ form.errors.code }}</small>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :label="__('Confirm')" :loading="form.processing" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import Input from '@/components/ui/Input.vue';
|
||||
import Select, { type SelectOption } from '@/components/ui/Select.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
import type { UserDetailsFormData } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
roles: SelectOption[];
|
||||
statuses: SelectOption[];
|
||||
countries: SelectOption[];
|
||||
errors: Record<string, string>;
|
||||
/** Role and status are fixed on your own profile. */
|
||||
locked?: boolean;
|
||||
/** Renders the submit button; the create page submits both cards at once. */
|
||||
submitLabel?: string;
|
||||
processing?: boolean;
|
||||
}>();
|
||||
|
||||
const form = defineModel<UserDetailsFormData>({ required: true });
|
||||
|
||||
const { __ } = useTranslations();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="role_id" class="mb-1 block text-slate-700">{{ __('Role') }}</label>
|
||||
<Select
|
||||
id="role_id"
|
||||
v-model="form.role_id"
|
||||
:options="roles"
|
||||
:disabled="locked"
|
||||
:invalid="Boolean(errors.role_id)"
|
||||
/>
|
||||
<small v-if="errors.role_id" class="text-red-600">{{ errors.role_id }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="mb-1 block text-slate-700">{{ __('Status') }}</label>
|
||||
<Select
|
||||
id="status"
|
||||
v-model="form.status"
|
||||
:options="statuses"
|
||||
:disabled="locked"
|
||||
:invalid="Boolean(errors.status)"
|
||||
/>
|
||||
<small v-if="errors.status" class="text-red-600">{{ errors.status }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="first_name" class="mb-1 block text-slate-700">{{ __('First Name') }}</label>
|
||||
<Input
|
||||
id="first_name"
|
||||
v-model="form.first_name"
|
||||
class="w-full"
|
||||
:placeholder="__('First Name')"
|
||||
:invalid="Boolean(errors.first_name)"
|
||||
/>
|
||||
<small v-if="errors.first_name" class="text-red-600">{{ errors.first_name }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="mb-1 block text-slate-700">{{ __('Last Name') }}</label>
|
||||
<Input
|
||||
id="last_name"
|
||||
v-model="form.last_name"
|
||||
class="w-full"
|
||||
:placeholder="__('Last Name')"
|
||||
:invalid="Boolean(errors.last_name)"
|
||||
/>
|
||||
<small v-if="errors.last_name" class="text-red-600">{{ errors.last_name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="birthday" class="mb-1 block text-slate-700">{{ __('Date of Birth') }}</label>
|
||||
<Input
|
||||
id="birthday"
|
||||
v-model="form.birthday"
|
||||
type="date"
|
||||
class="w-full"
|
||||
:invalid="Boolean(errors.birthday)"
|
||||
/>
|
||||
<small v-if="errors.birthday" class="text-red-600">{{ errors.birthday }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="mb-1 block text-slate-700">{{ __('Phone') }}</label>
|
||||
<Input
|
||||
id="phone"
|
||||
v-model="form.phone"
|
||||
class="w-full"
|
||||
:placeholder="__('Phone')"
|
||||
:invalid="Boolean(errors.phone)"
|
||||
/>
|
||||
<small v-if="errors.phone" class="text-red-600">{{ errors.phone }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="address" class="mb-1 block text-slate-700">{{ __('Address') }}</label>
|
||||
<Input
|
||||
id="address"
|
||||
v-model="form.address"
|
||||
class="w-full"
|
||||
:placeholder="__('Address')"
|
||||
:invalid="Boolean(errors.address)"
|
||||
/>
|
||||
<small v-if="errors.address" class="text-red-600">{{ errors.address }}</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="country_id" class="mb-1 block text-slate-700">{{ __('Country') }}</label>
|
||||
<Select
|
||||
id="country_id"
|
||||
v-model="form.country_id"
|
||||
:options="countries"
|
||||
:invalid="Boolean(errors.country_id)"
|
||||
/>
|
||||
<small v-if="errors.country_id" class="text-red-600">{{ errors.country_id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submitLabel" class="md:col-span-2">
|
||||
<Button type="submit" icon="fa fa-refresh" :label="submitLabel" :loading="processing" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import Button from '@/components/ui/Button.vue';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
const props = defineProps<{ count: number; reviewUrl: string }>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
|
||||
/**
|
||||
* The translation key carries its own <strong> markup, so it is kept verbatim
|
||||
* and rendered as HTML. The string comes from the application's own language
|
||||
* files, never from user input.
|
||||
*/
|
||||
const message = computed(() =>
|
||||
__('There are <strong>:count</strong> user(s) waiting on your approval to be able to access the application.', {
|
||||
count: props.count,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="count"
|
||||
class="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 p-4"
|
||||
>
|
||||
<p class="text-amber-900" v-html="message" />
|
||||
|
||||
<Link :href="reviewUrl">
|
||||
<Button :label="__('Review')" />
|
||||
</Link>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
interface LatestUser {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
registeredAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
defineProps<{ users: LatestUser[]; viewAllUrl: string }>();
|
||||
|
||||
const { __ } = useTranslations();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<h6 class="flex items-center justify-between border-b border-slate-100 px-6 py-4 font-medium">
|
||||
{{ __('Latest Registrations') }}
|
||||
|
||||
<Link
|
||||
v-if="users.length"
|
||||
:href="viewAllUrl"
|
||||
class="text-primary-600 text-sm font-normal hover:underline"
|
||||
>
|
||||
{{ __('View All') }}
|
||||
</Link>
|
||||
</h6>
|
||||
|
||||
<ul v-if="users.length" class="divide-y divide-slate-100">
|
||||
<li v-for="user in users" :key="user.id">
|
||||
<Link :href="user.url" class="flex items-center gap-3 px-6 py-3 hover:bg-slate-50">
|
||||
<img
|
||||
:src="user.avatar"
|
||||
width="40"
|
||||
height="40"
|
||||
:alt="user.name"
|
||||
class="rounded-full object-cover"
|
||||
/>
|
||||
<span class="leading-tight">
|
||||
<span class="block text-slate-800">{{ user.name }}</span>
|
||||
<small class="text-slate-500">{{ user.registeredAt }}</small>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="px-6 py-4 text-slate-500">{{ __('No records found.') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import LineChart from '@/components/LineChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
units: { new: string; user: string; users: string };
|
||||
}>();
|
||||
|
||||
/** Reproduces the old Chart.js tooltip: "3 new users" / "1 new user". */
|
||||
function formatValue(value: number): string {
|
||||
const unit = value === 1 ? props.units.user : props.units.users;
|
||||
|
||||
return `${value} ${props.units.new} ${unit}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<h6 class="border-b border-slate-100 px-6 py-4 font-medium">{{ title }}</h6>
|
||||
|
||||
<div class="px-4 py-6">
|
||||
<LineChart
|
||||
:labels="labels"
|
||||
:values="values"
|
||||
:label="title"
|
||||
:format-value="formatValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
count: number;
|
||||
icon: string;
|
||||
tone: 'primary' | 'success' | 'danger' | 'info';
|
||||
}>();
|
||||
|
||||
const TONES: Record<string, string> = {
|
||||
primary: 'text-primary-600',
|
||||
success: 'text-green-600',
|
||||
danger: 'text-red-600',
|
||||
info: 'text-sky-500',
|
||||
};
|
||||
|
||||
const formatted = computed(() => new Intl.NumberFormat().format(props.count));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-between rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<i :class="[icon, TONES[tone]]" class="text-4xl" />
|
||||
|
||||
<div class="text-right">
|
||||
<h2 class="text-3xl font-semibold text-slate-800">{{ formatted }}</h2>
|
||||
<div class="text-slate-500">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
interface Action {
|
||||
label: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
defineProps<{ actions: Action[] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
:href="action.url"
|
||||
class="rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm transition hover:border-primary-600"
|
||||
>
|
||||
<div class="text-primary-600 my-3">
|
||||
<i :class="action.icon" class="text-2xl" />
|
||||
</div>
|
||||
<p class="mb-0 text-lg text-slate-700">{{ action.label }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Component } from 'vue';
|
||||
import { resolveWidget } from '@/vanguard';
|
||||
import ApprovalNoticeWidget from './ApprovalNoticeWidget.vue';
|
||||
import LatestRegistrationsWidget from './LatestRegistrationsWidget.vue';
|
||||
import RegistrationHistoryWidget from './RegistrationHistoryWidget.vue';
|
||||
import StatWidget from './StatWidget.vue';
|
||||
import UserActionsWidget from './UserActionsWidget.vue';
|
||||
|
||||
/**
|
||||
* Dashboard widgets shipped by the core application.
|
||||
*
|
||||
* Plugin widgets use a namespaced name (e.g. "user-activity::ActivityWidget")
|
||||
* and are resolved from the Vanguard runtime instead.
|
||||
*/
|
||||
const CORE_WIDGETS: Record<string, Component> = {
|
||||
StatWidget,
|
||||
ApprovalNoticeWidget,
|
||||
LatestRegistrationsWidget,
|
||||
UserActionsWidget,
|
||||
RegistrationHistoryWidget,
|
||||
};
|
||||
|
||||
export function resolveWidgetComponent(name: string): Component | undefined {
|
||||
return CORE_WIDGETS[name] ?? resolveWidget(name);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export interface ConfirmRequest {
|
||||
header: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
/** Drives the icon and the confirm button colour. */
|
||||
severity: 'danger' | 'primary';
|
||||
accept: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module level for the same reason as useToast: `ConfirmHost` is mounted once in
|
||||
* the layout and renders whichever request is pending.
|
||||
*/
|
||||
const request = ref<ConfirmRequest | null>(null);
|
||||
const open = ref(false);
|
||||
|
||||
export function useConfirm() {
|
||||
function require(options: Partial<ConfirmRequest> & { message: string; accept: () => void }): void {
|
||||
request.value = {
|
||||
header: options.header ?? '',
|
||||
confirmLabel: options.confirmLabel ?? '',
|
||||
cancelLabel: options.cancelLabel ?? '',
|
||||
severity: options.severity ?? 'danger',
|
||||
message: options.message,
|
||||
accept: options.accept,
|
||||
};
|
||||
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only lowers the flag. The request itself is kept until the next `require`
|
||||
* so the dialog still has content to render while it closes, and so the
|
||||
* accept callback survives Reka closing the dialog before our click handler
|
||||
* runs.
|
||||
*/
|
||||
function close(): void {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
return { request, open, require, close };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { useConfirm } from '@/composables/useConfirm';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
|
||||
interface ConfirmDeleteOptions {
|
||||
/** Endpoint to DELETE. */
|
||||
url: string;
|
||||
/** Dialog heading. Defaults to "Please Confirm". */
|
||||
title?: string;
|
||||
/** Dialog body copy. */
|
||||
message: string;
|
||||
/** Confirm button label. Defaults to "Yes, delete it!". */
|
||||
confirmLabel?: string;
|
||||
/** HTTP verb, for the handful of destructive actions that are not DELETE. */
|
||||
method?: 'delete' | 'put' | 'post';
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a destructive action, then send it through the Inertia router.
|
||||
*
|
||||
* Every delete in the application goes through here, so the confirmation copy
|
||||
* and the request shape stay consistent.
|
||||
*/
|
||||
export function useConfirmDelete() {
|
||||
const confirm = useConfirm();
|
||||
const { __ } = useTranslations();
|
||||
|
||||
function confirmDelete(options: ConfirmDeleteOptions) {
|
||||
confirm.require({
|
||||
header: options.title ?? __('Please Confirm'),
|
||||
message: options.message,
|
||||
cancelLabel: __('Cancel'),
|
||||
confirmLabel: options.confirmLabel ?? __('Yes, delete it!'),
|
||||
severity: 'danger',
|
||||
accept: () => {
|
||||
router[options.method ?? 'delete'](
|
||||
options.url,
|
||||
{},
|
||||
{ preserveScroll: true, onSuccess: options.onSuccess },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { confirmDelete };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export type ToastSeverity = 'success' | 'error' | 'info' | 'warn';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number;
|
||||
severity: ToastSeverity;
|
||||
summary: string;
|
||||
/** Milliseconds before the toast dismisses itself. */
|
||||
life: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module level so any component can raise a toast without a provider being
|
||||
* injected above it, which is what PrimeVue's ToastService gave us. `ToastHost`
|
||||
* renders whatever is in here.
|
||||
*/
|
||||
const messages = reactive<ToastMessage[]>([]);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function useToast() {
|
||||
function add(message: { severity?: ToastSeverity; summary: string; life?: number }): void {
|
||||
messages.push({
|
||||
id: nextId++,
|
||||
severity: message.severity ?? 'info',
|
||||
summary: message.summary,
|
||||
life: message.life ?? 5000,
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id: number): void {
|
||||
const index = messages.findIndex((message) => message.id === id);
|
||||
|
||||
if (index !== -1) {
|
||||
messages.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, add, remove };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import type { SharedProps, SlotPayload } from '@/types';
|
||||
|
||||
/**
|
||||
* Translate a string using the JSON catalog shared by the server.
|
||||
*
|
||||
* Mirrors Laravel's __() helper: the English string is the key, and :placeholder
|
||||
* tokens are replaced from the supplied arguments.
|
||||
*/
|
||||
export function useTranslations() {
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
const __ = (key: string, replacements: Record<string, string | number> = {}): string => {
|
||||
// `||` rather than `??` to mirror Laravel's `$line ?: $key`. Plugins ship
|
||||
// English catalogs whose values are empty strings, and those must fall
|
||||
// back to the key exactly as they do server side.
|
||||
let line = page.props.translations?.[key] || key;
|
||||
|
||||
for (const [token, value] of Object.entries(replacements)) {
|
||||
line = line.replace(`:${token}`, String(value));
|
||||
}
|
||||
|
||||
return line;
|
||||
};
|
||||
|
||||
return { __ };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission and role checks, replacing the @permission / @role Blade directives.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
const permissions = computed(() => page.props.auth.permissions ?? []);
|
||||
|
||||
/** True when the user has every one of the given permissions. */
|
||||
const can = (...required: string[]): boolean =>
|
||||
required.every((permission) => permissions.value.includes(permission));
|
||||
|
||||
/** True when the user has at least one of the given permissions. */
|
||||
const canAny = (...required: string[]): boolean =>
|
||||
required.some((permission) => permissions.value.includes(permission));
|
||||
|
||||
return { permissions, can, canAny };
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticated user, or null for guest pages.
|
||||
*/
|
||||
export function useAuth() {
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
return {
|
||||
user: computed(() => page.props.auth.user),
|
||||
impersonating: computed(() => page.props.auth.impersonating),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared application setting.
|
||||
*/
|
||||
export function useSettings() {
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
const setting = <T = unknown>(key: string, fallback: T | null = null): T | null =>
|
||||
(page.props.settings?.[key] as T) ?? fallback;
|
||||
|
||||
return { setting };
|
||||
}
|
||||
|
||||
/**
|
||||
* Components registered by plugins for a named slot position.
|
||||
*/
|
||||
export function useSlot(name: string) {
|
||||
const page = usePage<SharedProps>();
|
||||
|
||||
return computed<SlotPayload[]>(() => page.props.vanguard?.slots?.[name] ?? []);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import { ref, watch } from 'vue';
|
||||
import TheNavbar from '@/components/TheNavbar.vue';
|
||||
import TheSidebar from '@/components/TheSidebar.vue';
|
||||
import ConfirmHost from '@/components/ui/ConfirmHost.vue';
|
||||
import ToastHost from '@/components/ui/ToastHost.vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useTranslations } from '@/composables/useVanguard';
|
||||
import type { SharedProps } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
heading?: string;
|
||||
breadcrumbs?: Array<{ label: string; href?: string }>;
|
||||
}>();
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
const toast = useToast();
|
||||
const { __ } = useTranslations();
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
/**
|
||||
* Flash messages arrive as a shared prop and are surfaced as toasts on every
|
||||
* navigation.
|
||||
*/
|
||||
watch(
|
||||
() => page.props.flash,
|
||||
(flash) => {
|
||||
for (const message of [flash?.success ?? []].flat().filter(Boolean)) {
|
||||
toast.add({ severity: 'success', summary: String(message), life: 3000 });
|
||||
}
|
||||
|
||||
for (const message of [flash?.error ?? []].flat().filter(Boolean)) {
|
||||
toast.add({ severity: 'error', summary: String(message), life: 5000 });
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="title" />
|
||||
|
||||
<TheNavbar :heading="heading" @toggle-sidebar="sidebarOpen = !sidebarOpen">
|
||||
<template #breadcrumbs>
|
||||
<ol
|
||||
v-if="breadcrumbs?.length"
|
||||
:aria-label="__('Breadcrumb')"
|
||||
class="hidden items-center gap-2 text-sm font-light text-slate-500 md:flex"
|
||||
>
|
||||
<li>
|
||||
<Link :href="route('dashboard')" class="text-slate-400 hover:text-primary-600">
|
||||
<i class="fa fa-home" />
|
||||
</Link>
|
||||
</li>
|
||||
<li v-for="crumb in breadcrumbs" :key="crumb.label" class="flex items-center gap-2">
|
||||
<span class="text-slate-300">/</span>
|
||||
<Link v-if="crumb.href" :href="crumb.href" class="hover:text-primary-600">
|
||||
{{ crumb.label }}
|
||||
</Link>
|
||||
<span v-else>{{ crumb.label }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</TheNavbar>
|
||||
|
||||
<TheSidebar :open="sidebarOpen" />
|
||||
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-[9] bg-black/40 md:hidden"
|
||||
:aria-label="__('Close navigation')"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<main class="mt-(--spacing-navbar) px-4 py-6 md:ml-(--spacing-sidebar)">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<ToastHost />
|
||||
<ConfirmHost />
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, usePage } from "@inertiajs/vue3";
|
||||
import { computed, watch } from "vue";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import LocaleDropdown from "@/components/LocaleDropdown.vue";
|
||||
import Message from "@/components/ui/Message.vue";
|
||||
import ToastHost from "@/components/ui/ToastHost.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { SharedProps } from "@/types";
|
||||
|
||||
defineProps<{ title?: string }>();
|
||||
|
||||
const page = usePage<SharedProps>();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* The auth controllers report flow failures with withErrors(string), which
|
||||
* Laravel files under the numeric key 0 rather than a field name. Those are
|
||||
* not tied to an input, so they belong in a banner; field errors keep being
|
||||
* rendered inline by the individual pages.
|
||||
*/
|
||||
const banner = computed<string | undefined>(() => {
|
||||
const errors = page.props.errors as Record<string, string> | undefined;
|
||||
|
||||
return errors?.general ?? errors?.[0];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => page.props.flash,
|
||||
(flash) => {
|
||||
for (const message of [flash?.success ?? []].flat().filter(Boolean)) {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: String(message),
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
for (const message of [flash?.error ?? []].flat().filter(Boolean)) {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: String(message),
|
||||
life: 8000,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="title" />
|
||||
|
||||
<div class="bg-body min-h-screen">
|
||||
<div class="flex justify-center items-center h-screen">
|
||||
<div class="mx-auto my-auto w-full max-w-md px-4 pb-16">
|
||||
<Message v-if="banner" severity="error" class="mb-4">
|
||||
{{ banner }}
|
||||
</Message>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The surface a plugin bundle links against.
|
||||
*
|
||||
* Plugins import this as `@vanguard/ui`, which their build externalises onto
|
||||
* `window.Vanguard.ui`. Anything exported here is public API for third party
|
||||
* plugins, so removing an export is a breaking change for them.
|
||||
*/
|
||||
export * from './components/ui';
|
||||
|
||||
export {
|
||||
useAuth,
|
||||
usePermissions,
|
||||
useSettings,
|
||||
useSlot,
|
||||
useTranslations,
|
||||
} from './composables/useVanguard';
|
||||
|
||||
export { useConfirmDelete } from './composables/useConfirmDelete';
|
||||
export { useConfirm } from './composables/useConfirm';
|
||||
export { useToast } from './composables/useToast';
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
import type { route as routeFn } from 'ziggy-js';
|
||||
|
||||
declare global {
|
||||
/** Provided by the ZiggyVue plugin (see resources/js/app.ts). */
|
||||
const route: typeof routeFn;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
/** Provided by the ZiggyVue plugin, for use inside templates. */
|
||||
route: typeof routeFn;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Page, PageProps as InertiaPageProps } from '@inertiajs/core';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
nameOrEmail: string;
|
||||
avatar: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
export interface SidebarItem {
|
||||
title: string;
|
||||
href: string | null;
|
||||
icon: string | null;
|
||||
activePath: string | null;
|
||||
expandedPath: Array<string | null> | null;
|
||||
children: SidebarItem[];
|
||||
}
|
||||
|
||||
export interface SlotPayload {
|
||||
component: string;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetPayload {
|
||||
component: string;
|
||||
props: Record<string, unknown>;
|
||||
width: string | null;
|
||||
}
|
||||
|
||||
export interface LocaleOption {
|
||||
code: string;
|
||||
flag: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props shared with every page by App\Http\Middleware\HandleInertiaRequests.
|
||||
*/
|
||||
export interface SharedProps extends InertiaPageProps {
|
||||
auth: {
|
||||
user: AuthUser | null;
|
||||
permissions: string[];
|
||||
impersonating: boolean;
|
||||
};
|
||||
settings: Record<string, unknown>;
|
||||
config: {
|
||||
databaseSessions: boolean;
|
||||
dateFormat: string;
|
||||
dateTimeFormat: string;
|
||||
};
|
||||
flash: {
|
||||
success: string | string[] | null;
|
||||
error: string | string[] | null;
|
||||
};
|
||||
errors: Record<string, string>;
|
||||
locale: string;
|
||||
locales: LocaleOption[];
|
||||
translations: Record<string, string>;
|
||||
vanguard: {
|
||||
sidebar: SidebarItem[];
|
||||
slots: Record<string, SlotPayload[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export type SharedPage = Page<SharedProps>;
|
||||
|
||||
/**
|
||||
* The editable user fields behind the details and login details forms, as
|
||||
* serialised by App\Support\UserPayload::form().
|
||||
*/
|
||||
export interface UserFormPayload {
|
||||
id: number;
|
||||
nameOrEmail: string;
|
||||
avatar: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
birthday: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
country_id: number;
|
||||
role_id: number;
|
||||
status: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
force_password_change: boolean;
|
||||
waitingApproval: boolean;
|
||||
}
|
||||
|
||||
/** The fields behind UserDetailsForm. Selects hand back strings. */
|
||||
export interface UserDetailsFormData {
|
||||
role_id: number | string;
|
||||
status: number | string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
birthday: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
country_id: number | string;
|
||||
}
|
||||
|
||||
/** The fields behind LoginDetailsForm. */
|
||||
export interface LoginDetailsFormData {
|
||||
email: string;
|
||||
username: string | null;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
force_password_change?: boolean;
|
||||
}
|
||||
|
||||
/** Two-factor state from App\Support\UserPayload::twoFactor(). */
|
||||
export interface TwoFactorState {
|
||||
enabled: boolean;
|
||||
needsVerification: boolean;
|
||||
action: 'enable' | 'disable' | 'verify';
|
||||
qrCode: string | null;
|
||||
}
|
||||
|
||||
export interface AvatarSource {
|
||||
label: string;
|
||||
url: string;
|
||||
external: boolean;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Component, DefineComponent } from 'vue';
|
||||
|
||||
/**
|
||||
* The registration surface handed to every plugin bundle.
|
||||
*/
|
||||
export interface PluginRegistrar {
|
||||
/** Register an Inertia page, e.g. `page('announcements::Index', Index)`. */
|
||||
page(name: string, component: DefineComponent): void;
|
||||
/**
|
||||
* Register a slot component by name, e.g.
|
||||
* `slot('announcements::NavbarBell', Bell)`.
|
||||
*
|
||||
* Which position a component fills is decided server side by
|
||||
* Vanguard::slot('navbar:items', NavbarItemsSlot::class), keeping placement
|
||||
* next to the permission and data logic that drives it.
|
||||
*/
|
||||
slot(name: string, component: Component): void;
|
||||
/** Register a dashboard widget component by name. */
|
||||
widget(name: string, component: Component): void;
|
||||
}
|
||||
|
||||
export type PluginCallback = (registrar: PluginRegistrar) => void;
|
||||
|
||||
/**
|
||||
* The queue shim installed inline in the root template. Plugin bundles call
|
||||
* `Vanguard.plugin(...)` which pushes onto `_q` until the real runtime boots,
|
||||
* so plugin scripts and the app bundle may load in any order.
|
||||
*/
|
||||
interface VanguardShim {
|
||||
_q?: Array<[string, PluginCallback]>;
|
||||
plugin(slug: string, callback: PluginCallback): void;
|
||||
}
|
||||
|
||||
const pages = new Map<string, DefineComponent>();
|
||||
const slots = new Map<string, Component>();
|
||||
const widgets = new Map<string, Component>();
|
||||
const registered = new Set<string>();
|
||||
|
||||
const registrar: PluginRegistrar = {
|
||||
page(name, component) {
|
||||
pages.set(name, component);
|
||||
},
|
||||
slot(name, component) {
|
||||
slots.set(name, component);
|
||||
},
|
||||
widget(name, component) {
|
||||
widgets.set(name, component);
|
||||
},
|
||||
};
|
||||
|
||||
function registerPlugin(slug: string, callback: PluginCallback): void {
|
||||
if (registered.has(slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered.add(slug);
|
||||
callback(registrar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the inline shim with the real runtime and drain anything that was
|
||||
* queued while the app bundle was still loading.
|
||||
*
|
||||
* `libs` are the shared dependencies plugin bundles externalise rather than
|
||||
* bundling themselves, which keeps a plugin bundle to just its own components.
|
||||
*/
|
||||
export function installVanguardRuntime(libs: Record<string, unknown>): void {
|
||||
const shim = (window as unknown as { Vanguard?: VanguardShim }).Vanguard;
|
||||
const queued = shim?._q ?? [];
|
||||
|
||||
(window as unknown as { Vanguard: unknown }).Vanguard = {
|
||||
...libs,
|
||||
plugin: registerPlugin,
|
||||
resolvePage,
|
||||
resolveSlot,
|
||||
resolveWidget,
|
||||
};
|
||||
|
||||
for (const [slug, callback] of queued) {
|
||||
registerPlugin(slug, callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once every deferred script in the document has run.
|
||||
*
|
||||
* Plugin bundles are module scripts at the end of the body, so they execute
|
||||
* after the app bundle that calls createInertiaApp. Waiting for them is what
|
||||
* lets the first page of a visit be a plugin page.
|
||||
*
|
||||
* Built at module load, which happens while the app bundle is still executing,
|
||||
* because `readyState` is already "interactive" by then: the spec sets it
|
||||
* before running deferred scripts, so checking it later would short circuit and
|
||||
* report ready while plugin bundles were still queued. The load listener is a
|
||||
* backstop for the window between DOMContentLoaded and readyState "complete".
|
||||
*/
|
||||
const pluginsLoaded: Promise<void> =
|
||||
document.readyState === 'complete'
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
document.addEventListener('DOMContentLoaded', () => resolve(), { once: true });
|
||||
window.addEventListener('load', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
/** Resolve a namespaced plugin page such as `announcements::Index`. */
|
||||
export function resolvePage(name: string): DefineComponent | Promise<DefineComponent> {
|
||||
const registered = pages.get(name);
|
||||
|
||||
if (registered) {
|
||||
return registered;
|
||||
}
|
||||
|
||||
return pluginsLoaded.then(() => {
|
||||
const component = pages.get(name);
|
||||
|
||||
if (!component) {
|
||||
throw new Error(
|
||||
`[Vanguard] Inertia page "${name}" was not registered by any plugin. ` +
|
||||
`Did you publish the plugin assets (php artisan vendor:publish --tag=public)?`,
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a slot component by name, e.g. `announcements::NavbarBell`. */
|
||||
export function resolveSlot(name: string): Component | undefined {
|
||||
return slots.get(name);
|
||||
}
|
||||
|
||||
/** Resolve a dashboard widget component by name. */
|
||||
export function resolveWidget(name: string): Component | undefined {
|
||||
return widgets.get(name);
|
||||
}
|
||||
Reference in New Issue
Block a user