first commit

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