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
+53
View File
@@ -0,0 +1,53 @@
@import 'tailwindcss';
@source '../js/**/*.{vue,ts}';
@source '../views/**/*.blade.php';
/*
* Vanguard design tokens.
*
* The primary ramp is derived from the historic Vanguard brand green (#179970,
* hsl(161, 74%, 34.5%)) which is kept as --color-primary-600 so existing brand
* usage renders identically to the Bootstrap theme it replaces.
*/
@theme {
--color-primary-50: #edfcf8;
--color-primary-100: #d3f8ed;
--color-primary-200: #a6f2da;
--color-primary-300: #71eac4;
--color-primary-400: #37e1ab;
--color-primary-500: #1ec892;
--color-primary-600: #179970;
--color-primary-700: #127c5b;
--color-primary-800: #0f6247;
--color-primary-900: #0a4231;
--color-primary-950: #072c20;
--color-body: #f5f8fa;
--color-lighter: #f8fbfc;
/* Form control fill, carried over from the Bootstrap theme's .input-solid. */
--color-field: #f8f9fa;
--color-sidebar: #30353e;
--color-sidebar-active: #3b414c;
--text-base: 0.9rem;
--leading-base: 1.6;
--spacing-navbar: 70px;
--spacing-sidebar: 250px;
--spacing-sidebar-collapsed: 80px;
}
@layer base {
html {
font-size: 100%;
}
body {
background-color: var(--color-body);
font-size: var(--text-base);
line-height: var(--leading-base);
color: var(--color-slate-700);
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Standalone stylesheet for the installation wizard.
*
* The installer runs before .env exists (see App\Http\Middleware\VerifyInstallation)
* so it deliberately stays plain Blade and must not depend on the Vue bundle.
*/
@import 'tailwindcss';
@source '../views/install/**/*.blade.php';
@source '../views/layouts/install.blade.php';
@source '../views/components/install/**/*.blade.php';
@theme {
--color-primary-600: #179970;
--color-primary-700: #127c5b;
--color-body: #f5f8fa;
--color-field: #f8f9fa;
}
+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);
}
+272
View File
@@ -0,0 +1,272 @@
{
"Password Reset Request": "Passwort zurücksetzen",
"New User Registration": "Neuer Benutzer anmelden",
"General": "Allgemein",
"Auth & Registration": "Authentifizierung & Registrierung",
"Notifications": "Berechtigungen",
"Settings": "Einstellungen",
"Roles": "Rollen",
"Permissions": "Berechtigungen",
"Roles & Permissions": "Rollen und Berechtigungen",
"Dashboard": "Dashboard",
"Users": "Benutzer",
"You have to accept Terms of Service.": "Sie müssen sie Nutzungsbedingungen bestätigen.",
"Permission with this name already exists.": "Eine Berechtigung mit diesem Namen existiert bereits!",
"Your account is banned by administrator.": "Ihr Benutzerkonto wurde von einem Administrator gesperrt.",
"Something went wrong during the authentication process. Please try again.": "Während des Authentifizierungsvorgangs ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"Only users who already created an account can log in.": "Nur Benutzer mit einem gültigen Benutzerkonto können sich anmelden.",
"You have to provide your email address.": "Sie müssen ihre eMail-Adresse angeben.",
"Your account is created successfully! Please confirm your email.": "Ihr Benutzerkonto wurde erfolgreich erstellt. Bite bestätigen Sie noch ihre eMail-Adresse, damit Sie sich anmelden können.",
"Your account is created successfully!": "Ihr Benutzerkonto wurde erfolgreich erstellt.",
"2FA Token is invalid!": "Der 2-Faktor Token ist ungültig!",
"Two-Factor Authentication enabled successfully.": "Die 2-Faktor Authentisierung wurde erfolgreich aktiviert.",
"Two-Factor Authentication disabled successfully.": "Die 2-Faktor Authentisierung wurde erfolgreich deaktiviert.",
"Avatar image cannot be updated. Please try again.": "Avatarbild kann nicht aktualisiert werden. Bitte versuche es erneut.",
"Avatar changed successfully.": "Avatar erfolgreich geändert.",
"Session invalidated successfully.": "Sie Sitzung wurde erfolgreich abgebrochen.",
"Select a Country": "Wähle ein Land",
"Profile updated successfully.": "Profil erfolgreich aktualisiert.",
"Before continuing please update your password and set a new one.": "Bevor Sie fortfahren, aktualisieren Sie bitte Ihr Passwort und legen Sie ein neues fest.",
"Login details updated successfully.": "Anmeldedaten erfolgreich aktualisiert.",
"All": "Alle",
"User created successfully.": "Benutzerkonto erfolgreich erstellt.",
"You cannot delete yourself.": "Sie können ihr eigenes Benutzerkonto nicht löschen.",
"User deleted successfully.": "Benutzerkonto erfolgreich gelöscht.",
"User updated successfully.": "Benutzerkonto erfolgreich aktualisiert.",
"Settings updated successfully.": "Einstellungen erfolgreich aktualisiert.",
"reCAPTCHA enabled successfully.": "reCAPTCHA erfolgreich aktiviert.",
"reCAPTCHA disabled successfully.": "reCAPTCHA erfolgreich deaktiviert.",
"User Confirmation Flow": "Ablauf der Benutzerbestätigung",
"User Confirmation Flow enabled successfully.": "Benutzerbestätigungsablauf erfolgreich aktiviert.",
"User Confirmation Flow disabled successfully.": "Benutzerbestätigungsablauf erfolgreich deaktiviert.",
"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.": "Aktivieren/Deaktivieren Sie den Benutzerbestätigungsablauf für die Anwendung. Wenn diese Option aktiviert ist, werden Benutzer auf „Warten auf Genehmigung“ gesetzt und benötigen die Genehmigung des Administrators, bevor sie aktiv werden.",
"Permissions saved successfully.": "Berechtigung erfolgreich gespeichert.",
"Permission created successfully.": "Berechtigung erfolgreich erstellt.",
"Permission updated successfully.": "Berechtigung erfolgreich aktualisiert.",
"Permission deleted successfully.": "Berechtigung erfolgreich gelöscht.",
"Role created successfully.": "Rolle erfolgreich erstellt.",
"Role updated successfully.": "Rolle erfolgreich aktualisiert.",
"Role deleted successfully.": "Rolle erfolgreich gelöscht.",
"permission name": "berechtigung name",
"Authentication Settings": "Authetifizierungseinstellungen",
"Authentication": "Authentifizierung",
"Registration": "Registrierung",
"Notification Settings": "Benachrichtigungseinstellungen",
"Email Notifications": "eMail Benachrichtigungen",
"Sign-Up Notification": "Anmelden Benachrichtigung",
"Send an email to the Administrators when user signs up.": "Administratoren benachrichtigen, wenn sich neue Benutzer registrieren.",
"Update": "Aktualisieren",
"General Settings": "Allgemeine Einstellungen",
"Name": "Name",
"Allow \"Remember Me\"": "\"Anmeldedaten merken\" zulassen",
"Should 'Remember Me' checkbox be displayed on login form?": "Soll die Option 'Anmeldedaten merken' im Anmeldeformular angezeigt werden?",
"Forgot Password": "Passwort vergessen",
"Enable\/Disable forgot password feature.": "Passwort vergessen Feature ein/ausschalten.",
"Reset Token Lifetime": "Gültigkeitsdauer des Passwort-Reset Tokens",
"Number of minutes that the reset token should be considered valid.": "Dauer in Minuten wie lange der Passwort-Reset Token gültig ist.",
"Two-Factor Authentication": "Zwei-Faktor Authentifizierung",
"Enable\/Disable Two-Factor Authentication for the application.": "Aktivieren/Deaktivieren Zwei-Faktor Authentifizierung für die Anwendung.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication.": "Wenn die Zwei-Faktor-Authentifizierung aktiviert ist, werden Sie während der Authentifizierung zur Eingabe eines sicheren, zufälligen Tokens aufgefordert.",
"You may retrieve this token from your phone's Authenticator application.": "Sie können dieses Token aus der Authenticator-Anwendung Ihres Telefons abrufen.",
"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.": "Um die Aktivierung der Zwei-Faktor-Authentifizierung abzuschließen, scannen Sie den folgenden QR-Code mit der Authentifizierungsanwendung Ihres Telefons oder geben Sie den Setup-Schlüssel ein und geben Sie den generierten OTP-Code ein.",
"In order to enable Two-Factor Authentication you have to register and create new application on": "Um die 2-Faktor Authentisierung zu aktivieren, müssen Sie sich registrieren und eine neue Applikation auf der",
"Authy website": "Authy Webseite",
"and update your": "und aktualisieren Sie Ihre",
"environment variable inside": "Umgebungsvariablen aktualisieren",
"file": "file",
"Disabling...": "Ausschalten ...",
"Disable": "Ausschalten",
"Enabling...": "Einschalten ...",
"Enable": "Einschalten",
"Authentication Throttling": "Anmelde-Verlangsamung",
"Throttle Authentication": "Authentisierungsprozess verlangsamen",
"Should the system throttle authentication attempts?": "Authentisierungsprozess verlangsamen?",
"Maximum Number of Attempts": "Maximale Anzahl an Versuchen",
"Maximum number of incorrect login attempts before lockout.": "Maximale Anzahl an fehlerhaften Anmeldeversuchen bevor der Zugang gesperrt wird.",
"Lockout Time": "Sperrzeit",
"Number of minutes to lock the user out for after specified maximum number of incorrect login attempts.": "Dauer in Minuten wie lange ein Benutzer nach der festgelgten Anzahl fehlerhafter Anmeldeversuche gesperrt ist.",
"Allow Registration": "Registrierung erlauben",
"Terms & Conditions": "Nutzungsbedingungen",
"The user has to confirm that he agree with terms and conditions in order to create an account.": "Der Benutzer muss den Nutzungsbedingungen zustimmen, um ein Benutzerkonto erstellen zu können.",
"Email Confirmation": "eMail Bestätigung",
"Require email confirmation from your newly registered users.": "Erfordern E-Mail-Bestätigung von Ihren neu registrierten Benutzern.",
"Google reCAPTCHA": "Google reCAPTCHA",
"Approve": "Genehmigen",
"Approve User": "Benutzer genehmigen",
"User Approval": "Benutzerfreigabe",
"The user is waiting on your approval to be able to access the website.": "Der Benutzer wartet auf Ihre Genehmigung, um auf die Website zugreifen zu können.",
"Your account has been approved by administrators. You can now log in now and start using the application.": "Ihr Konto wurde von den Administratoren genehmigt und Sie können sich jetzt anmelden und die Anwendung verwenden.",
"Enable\/Disable Google reCAPTCHA during the registration.": "Aktivieren/Aktivieren reCAPTCHA.",
"To utilize Google reCAPTCHA, please get your": "Um die Google reCAPTCHA Funktion nutzen zu können, beantragen Sie bitte ihren",
"Site Key": "Site-Schlüssel",
"and": "und",
"Secret Key": "Secret Key",
"from": "von",
"reCAPTCHA Website": "reCAPTCHA Webseite",
"environment variables inside": "Umgebungsvariablen innerhalb",
"Add Role": "Rolle hinzufügen",
"Display Name": "Anzeigename",
"# of users with this role": "# Benutzer mit dieser Rolle",
"Action": "Aktion",
"Edit Role": "Rolle ändern",
"Delete Role": "Rolle löschen",
"Please Confirm": "Bitte bestätigen",
"Are you sure that you want to delete this role?": "Sind Sie sicher, dass Sie diese Rolle löschen wollen?",
"Yes, delete it!": "Ja, löschen!",
"No records found.": "Keine Einträge gefunden.",
"Create New Role": "Neue Benutzerrolle erstellen",
"Role Details": "Rollen-Einstellungen",
"A general role information.": "Rollen-Einstellungen",
"Role Name": "Rollen Name",
"Description": "Beschreibung",
"Token": "Token",
"Authy 2FA Token": "Authy 2FA Token",
"2FA Code": "2FA Code",
"Validate": "Überprüfen",
"Sign Up": "Registrieren",
"Register": "Registrieren",
"Email": "eMail",
"Username": "Benutzername",
"Password": "Passwort",
"Confirm Password": "Passwort bestätigen",
"I accept": "Ich akzeptiere",
"Terms of Service": "Nutzungsbedingungen",
"Already have an account?": "Habe schon einen Account?",
"Login": "Anmelden",
"Close": "Schließen",
"Reset Password": "Passwort zurücksetzen",
"Forgot Your Password?": "Passwort vergessen?",
"Please finish configuring Two-Factor authentication below.": "Bitte schließen Sie unten die Konfiguration der Zwei-Faktor-Authentifizierung ab.",
"Please provide your email below and we will send you a password reset link.": "Bitte geben Sie unten Ihre E-Mail-Adresse an und wir senden Ihnen einen Link zum Zurücksetzen des Passworts.",
"Your E-Mail": "Ihre eMail-Adresse",
"Reset Your Password": "Passwort zurücksetzen",
"Please provide your email and pick a new password below.": "Bitte geben Sie Ihre E-Mail Adresse ein und wählen Sie ein neues Passwort aus.",
"New Password": "Neues Passwort",
"Confirm New Password": "Neues Passwort bestätigen",
"Update Password": "Passwort aktualisieren",
"Verify Your Email Address": "Überprüfen Sie Ihre E-Mail-Adresse",
"A fresh verification link has been sent to your email address.": "Ein neuer Bestätigungslink wurde an Ihre E-Mail-Adresse gesendet.",
"Before proceeding, please check your email for a verification link.": "Before proceeding, please check your email for a verification link.",
"If you did not receive the email": "Wenn Sie die E-Mail nicht erhalten haben",
"click here to request another": "klicken Sie hier, um ein anderes anzufordern",
"Email or Username": "eMail oder Benutzername",
"Remember me?": "Anmeldedaten merken?",
"Log In": "Anmelden",
"I forgot my password": "Passwort vergessen?",
"Don't have an account?": "Sie haben noch keinen Account?",
"Banned Users": "Gesperrte Benutzer",
"Total Users": "Gesamte Benutzeranzahl",
"Latest Registrations": "Letzte Registrierungen",
"View All": "Alle ansehen",
"Update Profile": "Profil aktualisieren",
"My Sessions": "Meine Sitzungen",
"Activity Log": "Aktivitäts-Log",
"Logout": "Abmelden",
"Registration History": "Registrierungshistorie",
"Unconfirmed Users": "Unbestätigte Benutzer",
"new": "neu",
"user": "benutzer",
"users": "benutzer",
"New Users": "Neue Benutzer",
"Hello!": "Hallo!",
"You are receiving this email because we received a password reset request for your account.": "Sie erhalten diese E-Mail, weil wir eine Aufforderung zum Zurücksetzen des Passworts für Ihr Konto erhalten haben.",
"This password reset link will expire in :count minutes.": "Dieser Link zum Zurücksetzen des Passworts läuft in :count Minuten ab.",
"If you did not request a password reset, no further action is required.": "Wenn Sie kein Zurücksetzen des Kennworts angefordert haben, ist keine weitere Aktion erforderlich.",
"Regards": "Grüße",
"New user was just registered on :app website.": "Ein neuer Benutzer wurde eben auf der WebSeite :app registriert.",
"To view the user details just visit the link below.": "Um die Benutzerdetails anzusehen, klicken Sie bitte auf den Link unten.",
"View User": "Benutzer ansehen",
"My Profile": "Mein Profil",
"Two-Factor Phone Verification": "Zwei-Faktor Überprüfung der Telefonnummer",
"Phone Verification": "Überprüfung der Telefonnummer",
"Verifying...": "Überprüfung...",
"Verify": "Überprüfen",
"Sending...": "Senden...",
"Resend Token": "Erneut senden Token",
"Edit User": "Benutzer ändern",
"Edit": "Ändern",
"User Details": "Benutzereinstellungen",
"Login Details": "Anmeldedetails",
"Add User": "Benutzer hinzufügen",
"Create New User": "Neuen Benutzer erstellen",
"Create": "Erstellen",
"A general user profile information.": "Allgemeine Benutzerprofilinformationen.",
"Details used for authenticating with the application.": "Details verwendet mit der Anwendung zur Authentifizierung.",
"Create User": "Benutzer erstellen",
"Active Sessions": "Aktive Sitzungen",
"Sessions": "Sitzungen",
"IP Address": "IP-Addresse",
"Device": "Device",
"Browser": "Browser",
"Last Activity": "Letzte Aktivität",
"Unknown": "Unbekannt",
"Invalidate Session": "Session unterbrechen",
"Are you sure that you want to invalidate this session?": "Sind Sie sicher diese Sitzung zu unterbrechen?",
"Yes, proceed!": "Ja, fortfahren!",
"Search for users...": "Nach Benutzern suchen...",
"Full Name": "Vollständiger Name",
"Registration Date": "Registrierungsdatum",
"Status": "Status",
"Details": "Details",
"Impersonate User": "Impersonate User",
"Impersonate": "Impersonate",
"Phone": "Rufnummer",
"Birthday": "Geburtsdatum",
"Address": "Addresse",
"Last Logged In": "Letzter Login",
"optional": "optional",
"Leave field blank if you don't want to change it": "Lassen Sie das Feld leer wenn Sie hier nichts verändern wollen",
"Update Details": "Details aktualisieren",
"Change Photo": "Bild ändern",
"Cancel": "Abbrechen",
"Save": "Speichern",
"No Photo": "Kein Bild",
"Upload Photo": "Bild hochladen",
"Gravatar": "Gravatar",
"In order to enable Two-Factor Authentication, you must install": "Um die 2-Faktor Authetisierung zu aktivieren, müssen Sie die",
"application on your phone": "Applikation auf ihrem Handy installieren",
"Country Code": "Ländercode",
"Cell Phone": "Mobilfunknummer",
"Phone without country code": "Rufnummer ohne Ländercode",
"N\/A": "-",
"User Sessions": "Benutzer-Sitzungen",
"Delete User": "Benutzer löschen",
"Are you sure that you want to delete this user?": "Sind Sie sicher diesen Benutzer vom System zu entfernen?",
"Yes, delete him!": "Ja, Benutzer löschen!",
"Role": "Rolle",
"First Name": "Vorname",
"Last Name": "Nachname",
"Date of Birth": "Geburtsdatum",
"Country": "Land",
"Activity": "Aktivitäten",
"Last Two Weeks": "Letzte 2 Wochen",
"action": "Aktion",
"actions": "aktionen",
"Add Permission": "Berechtigung hinzufügen",
"Edit Permission": "Berechtigung ändern",
"Delete Permission": "Berechtigung löschen",
"Are you sure that you want to delete this permission?": "Sind Sie sicher, dass Sie diese Berechtigung löschen wollen?",
"Save Permissions": "Berechtigungen speichern",
"Create New Permission": "Neue Berechtigung erstellen",
"Permission Details": "Berechtigungen Details",
"A general permission information.": "Berechtigungen Details",
"Permission Name": "Berechtigung Name",
"Stop Impersonating": "Stop Impersonating",
"All rights reserved.": "Alle Rechte vorbehalten.",
"If you're having trouble clicking the \":button\" button, copy and paste the URL below into your web browser:": "Wenn Sie Probleme haben, auf die Schaltfläche \":button\" zu klicken, kopieren Sie die unten stehende URL und fügen Sie sie in Ihren Webbrowser ein:",
"E-Mail verified successfully.": "E-Mail erfolgreich verifiziert.",
"Max Number of Active Sessions": "Maximale Anzahl aktiver Sitzungen",
"Maximum number of active sessions per user. Set to 0 to allow unlimited number of active sessions.": "Maximale Anzahl aktiver Sitzungen pro Benutzer. Auf 0 setzen, um eine unbegrenzte Anzahl aktiver Sitzungen zuzulassen.",
"Only applies when using database session driver.": "Gilt nur bei Verwendung des Datenbanksitzungstreibers.",
"Force Password Change On Next Login": "Kennwortänderung bei der nächsten Anmeldung erzwingen",
"Force all the users created manually by admin to reset the password on their first log in to be able to use the application.": "Erzwingen Sie, dass alle vom Administrator manuell erstellten Benutzer bei ihrer ersten Anmeldung das Kennwort zurücksetzen, um die Anwendung verwenden zu können.",
"Force Password Change for users enabled successfully.": "Erzwungene Kennwortänderung für Benutzer erfolgreich aktiviert.",
"Force Password Change for users disabled successfully.": "Erzwungene Kennwortänderung für Benutzer erfolgreich deaktiviert.",
"There are <strong>:count</strong> user(s) waiting on your approval to be able to access the application.": "Es warten <strong>:count</strong> Benutzer auf Ihre Genehmigung, um auf die Anwendung zugreifen zu können.",
"Review": "Rezension",
"Are you sure that you want to approve this user?": "Sind Sie sicher, dass Sie diesen Benutzer zulassen möchten?",
"Force Password Change on next login": "Kennwortänderung bei der nächsten Anmeldung erzwingen",
"This action is disabled in the demo.": "Diese Aktion ist in der Demo deaktiviert.",
"The demo account cannot be modified.": "Das Demo-Konto kann nicht geändert werden.",
"Default roles and permissions cannot be modified in the demo.": "Standardrollen und -berechtigungen können in der Demo nicht geändert werden."
}
+25
View File
@@ -0,0 +1,25 @@
<?php
return [
'months' => [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
],
'status' => [
'Active' => 'Aktiv',
'Banned' => 'Verboten',
'Unconfirmed' => 'Unbestätigte',
'Waiting Approval' => 'Warten auf Genehmigung',
],
];
+31
View File
@@ -0,0 +1,31 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Diese Zugangsdaten wurden nicht in unserer Datenbank gefunden.',
'throttle' => 'Zu viele Login Versuche. Versuchen Sie es bitte in :seconds Sekunden.',
'banned' => 'Ihr Benutzerkonto wurde von einem Administrator gesperrt.',
'approval' => 'Ihr Konto wartet auf die Genehmigung durch die Administratoren.',
'max_sessions_reached' => 'Sie haben die maximal zulässige Anzahl aktiver Sitzungen erreicht. Bitte melden Sie sich auf anderen Geräten ab und versuchen Sie es erneut.',
'2fa' => [
'enabled_successfully' => 'Zwei-Faktor-Authentifizierung erfolgreich aktiviert.',
'disabled_successfully' => 'Zwei-Faktor-Authentifizierung erfolgreich deaktiviert.',
'already_enabled' => '2FA ist für diesen Benutzer bereits aktiviert.',
'not_enabled' => '2FA ist für diesen Benutzer nicht aktiviert.',
'phone_in_use' => 'Es gibt bereits einen Benutzer mit der angegebenen Telefonnummer und Landesvorwahl.',
'invalid_token' => 'Ungültiges 2FA-Token.',
'token_sent' => 'Verifizierungstoken gesendet.',
],
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; zurück',
'next' => 'weiter &raquo;',
];
+22
View File
@@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reminder Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'password' => 'Passwörter müssen mindestens 8 Zeichen lang sein und korrekt bestätigt werden.',
'reset' => 'Das Passwort wurde zurückgesetzt!',
'sent' => 'Passworterinnerung wurde gesendet!',
'token' => 'Der Passwort-Wiederherstellungs-Schlüssel ist ungültig oder abgelaufen.',
'user' => 'Es konnte leider kein Nutzer mit dieser E-Mail Adresse gefunden werden.',
];
+115
View File
@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| such as the size rules. Feel free to tweak each of these messages.
|
*/
'accepted' => ':attribute muss akzeptiert werden.',
'active_url' => ':attribute ist keine gültige Internet-Adresse.',
'after' => ':attribute muss ein Datum nach dem :date sein.',
'alpha' => ':attribute darf nur aus Buchstaben bestehen.',
'alpha_dash' => ':attribute darf nur aus Buchstaben, Zahlen, Binde- und Unterstrichen bestehen. Umlaute (ä, ö, ü) und Eszett (ß) sind nicht erlaubt.',
'alpha_num' => ':attribute darf nur aus Buchstaben und Zahlen bestehen.',
'array' => ':attribute muss ein Array sein.',
'before' => ':attribute muss ein Datum vor dem :date sein.',
'between' => [
'numeric' => ':attribute muss zwischen :min & :max liegen.',
'file' => ':attribute muss zwischen :min & :max Kilobytes groß sein.',
'string' => ':attribute muss zwischen :min & :max Zeichen lang sein.',
'array' => ':attribute muss zwischen :min & :max Elemente haben.',
],
'boolean' => ":attribute muss entweder 'true' oder 'false' sein.",
'confirmed' => ':attribute stimmt nicht mit der Bestätigung überein.',
'date' => ':attribute muss ein gültiges Datum sein.',
'date_format' => ':attribute entspricht nicht dem gültigen Format für :format.',
'different' => ':attribute und :other müssen sich unterscheiden.',
'digits' => ':attribute muss :digits Stellen haben.',
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => ':attribute Format ist ungültig.',
'exists' => 'Der gewählte Wert für :attribute ist ungültig.',
'filled' => ':attribute muss ausgefüllt sein.',
'image' => ':attribute muss ein Bild sein.',
'in' => 'Der gewählte Wert für :attribute ist ungültig.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => ':attribute muss eine ganze Zahl sein.',
'ip' => ':attribute muss eine gültige IP-Adresse sein.',
'json' => ':attribute muss ein gültiger JSON-String sein.',
'max' => [
'numeric' => ':attribute darf maximal :max sein.',
'file' => ':attribute darf maximal :max Kilobytes groß sein.',
'string' => ':attribute darf maximal :max Zeichen haben.',
'array' => ':attribute darf nicht mehr als :max Elemente haben.',
],
'mimes' => ':attribute muss den Dateityp :values haben.',
'min' => [
'numeric' => ':attribute muss mindestens :min sein.',
'file' => ':attribute muss mindestens :min Kilobytes groß sein.',
'string' => ':attribute muss mindestens :min Zeichen lang sein.',
'array' => ':attribute muss mindestens :min Elemente haben.',
],
'not_in' => 'Der gewählte Wert für :attribute ist ungültig.',
'numeric' => ':attribute muss eine Zahl sein.',
'present' => 'The :attribute field must be present.',
'regex' => ':attribute Format ist ungültig.',
'required' => ':attribute muss ausgefüllt sein.',
'required_if' => ':attribute muss ausgefüllt sein, wenn :other :value ist.',
'required_unless' => ':attribute muss ausgefüllt sein, wenn :other nicht :values ist.',
'required_with' => ':attribute muss angegeben werden, wenn :values ausgefüllt wurde.',
'required_with_all' => ':attribute muss angegeben werden, wenn :values ausgefüllt wurde.',
'required_without' => ':attribute muss angegeben werden, wenn :values nicht ausgefüllt wurde.',
'required_without_all' => ':attribute muss angegeben werden, wenn keines der Felder :values ausgefüllt wurde.',
'same' => ':attribute und :other müssen übereinstimmen.',
'size' => [
'numeric' => ':attribute muss gleich :size sein.',
'file' => ':attribute muss :size Kilobyte groß sein.',
'string' => ':attribute muss :size Zeichen lang sein.',
'array' => ':attribute muss genau :size Elemente haben.',
],
'string' => ':attribute muss ein String sein.',
'timezone' => ':attribute muss eine gültige Zeitzone sein.',
'unique' => ':attribute ist schon vergeben.',
'url' => 'Das Format von :attribute ist ungültig.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap attribute place-holders
| with something more reader friendly such as E-Mail Address instead
| of "email". This simply helps us make messages a little cleaner.
|
*/
'attributes' => [
//
],
];
+25
View File
@@ -0,0 +1,25 @@
<?php
return [
'months' => [
1 => 'January',
2 => 'February',
3 => 'March',
4 => 'April',
5 => 'May',
6 => 'June',
7 => 'July',
8 => 'August',
9 => 'September',
10 => 'October',
11 => 'November',
12 => 'December',
],
'status' => [
'Active' => 'Active',
'Banned' => 'Banned',
'Unconfirmed' => 'Unconfirmed',
'Waiting Approval' => 'Waiting Approval',
],
];
+31
View File
@@ -0,0 +1,31 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'banned' => 'Your account is banned by administrator.',
'approval' => 'Your account is waiting approval from administrators.',
'max_sessions_reached' => 'You reached the maximum number of active sessions allowed. Please log out on other devices and try again.',
'2fa' => [
'enabled_successfully' => 'Two-Factor Authentication enabled successfully.',
'disabled_successfully' => 'Two-Factor Authentication disabled successfully.',
'already_enabled' => '2FA is already enabled for this user.',
'not_enabled' => '2FA is not enabled for this user.',
'phone_in_use' => 'There is already an user with provided phone number and country code.',
'invalid_token' => 'Invalid 2FA token.',
'token_sent' => 'Verification token sent.',
],
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reminder Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'password' => 'Passwords must be at least eight characters and match the confirmation.',
'reset' => 'Your password has been reset!',
'sent' => 'If an account exists for the provided email address, you should receive a password reset link shortly.',
'token' => 'This password reset token is invalid.',
'user' => 'If an account exists for the provided email address, you should receive a password reset link shortly.',
];
+110
View File
@@ -0,0 +1,110 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute must be accepted.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'before' => 'The :attribute must be a date before :date.',
'between' => [
'numeric' => 'The :attribute must be between :min and :max.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'string' => 'The :attribute must be between :min and :max characters.',
'array' => 'The :attribute must have between :min and :max items.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'email' => 'The :attribute must be a valid email address.',
'exists' => 'The selected :attribute is invalid.',
'filled' => 'The :attribute field is required.',
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'json' => 'The :attribute must be a valid JSON string.',
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
'string' => 'The :attribute may not be greater than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mimes' => 'The :attribute must be a file of type: :values.',
'min' => [
'numeric' => 'The :attribute must be at least :min.',
'file' => 'The :attribute must be at least :min kilobytes.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
],
'not_in' => 'The selected :attribute is invalid.',
'numeric' => 'The :attribute must be a number.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.',
'array' => 'The :attribute must contain :size items.',
],
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid zone.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute format is invalid.',
'captcha' => 'reCAPTCHA value is invalid.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap attribute place-holders
| with something more reader friendly such as E-Mail Address instead
| of "email". This simply helps us make messages a little cleaner.
|
*/
'attributes' => [],
];
+274
View File
@@ -0,0 +1,274 @@
{
"# of users with this role": "broj korisnika sa ovom ulogom",
"2FA Code": "2FA kod",
"2FA Token is invalid!": "2FA token nije validan!",
"A fresh verification link has been sent to your email address.": "Novi verfikacioni link je poslat na Vašu mejl adresu.",
"A general permission information.": "Osnovne informacije o dozvoli.",
"A general role information.": "Osnovne informacije o ulozi.",
"A general user profile information.": "Osnovne informacije o profilu korisnika.",
"Action": "Akcija",
"action": "akcija",
"actions": "akcije",
"Active Sessions": "Aktivne Sesije",
"Activity": "Aktivnosti",
"Activity Log": "Aktivnost",
"Add Permission": "Dodaj Dozvolu",
"Add Role": "Dodaj Ulogu",
"Add User": "Dodaj Korisnika",
"Address": "Adresa",
"All": "Sve",
"All rights reserved.": "Sva prava zadržana.",
"Allow \"Remember Me\"": "Dozvoli \"Zapamti me\"",
"Allow Registration": "Dozvoli registraciju",
"Already have an account?": "Već imate kreiran nalog?",
"and": "i",
"and update your": "i ažurirajte Vaš",
"application on your phone": "aplikaciju na Vašem telefonu",
"Are you sure that you want to delete this permission?": "Da li ste sigurni da želite da obrišete ovu dozvolu?",
"Are you sure that you want to delete this role?": "Da li ste sigurni da želite da obrišete ovu ulogu?",
"Are you sure that you want to delete this user?": "Da li ste sigurni da želite da obrišete ovog korisnika?",
"Are you sure that you want to invalidate this session?": "Da li ste sigurni da želite da poništite ovu sesiju?",
"Auth & Registration": "Autentifikacija i registracija",
"Authentication": "Autentifikacija",
"Authentication Settings": "Podešavanje autentifikacije",
"Authentication Throttling": "\"Propusnost\" prilikom autentifikacije",
"Authy 2FA Token": "Authy 2FA Token",
"Authy website": "Authy sajt",
"Avatar changed successfully.": "Avatar je uspešno ažuriran.",
"Avatar image cannot be updated. Please try again.": "Avatar ne može biti ažuriran, molimo Vas pokušajte ponovo.",
"Approve": "Odobri",
"Approve User": "Odobri korisnika",
"Banned Users": "Blokirani korisnici",
"Before continuing please update your password and set a new one.": "Pre nego što nastavite, ažurirajte svoju lozinku i postavite novu.",
"Before proceeding, please check your email for a verification link.": "Pre nego što nastavite, proverite Vašu elektronsku poštu kako biste verifikovali vašu mejl adresu.",
"Birthday": "Rođendan",
"Browser": "Pretraživač",
"Cancel": "Odustani",
"Cell Phone": "Mobilni Telefon",
"Change Photo": "Promeni Avatar",
"click here to request another": "kliknite ovde kako biste poslali novi zahtev",
"Close": "Zatvori",
"Confirm New Password": "Potvrdite Novu Lozinku",
"Confirm Password": "Potvrdite Lozinku",
"Country": "Država",
"Country Code": "Pozivni Broj",
"Create": "Kreiraj",
"Create New Permission": "Kreiraj Novu Dozvolu",
"Create New Role": "Kreiraj Novu Dozvolu",
"Create New User": "Kreiraj Novog Korisnika",
"Create User": "Kreiraj Korisnika",
"Dashboard": "Kontrolna tabla",
"Date of Birth": "Datum Rođenja",
"Delete Permission": "Obriši Dozvolu",
"Delete Role": "Obriši Ulogu",
"Delete User": "Obriši Korisnika",
"Description": "Opis",
"Details": "Detalji",
"Details used for authenticating with the application.": "Podaci koji se koriste prilikom autentifikacije.",
"Device": "Uređaj",
"Disable": "Isključi",
"Disabling...": "Isključivanje...",
"Display Name": "Ime za prikaz",
"Don't have an account?": "Nemate nalog?",
"Edit": "Izmeni",
"Edit Permission": "Izmeni Dozvolu",
"Edit Role": "Izmeni Ulogu",
"Edit User": "Izmeni Korisnika",
"Email": "Email Adresa",
"Email Confirmation": "Potvrda Email Adrese",
"Email Notifications": "Email Obaveštenja",
"Email or Username": "Email ili korisničko ime",
"Enable": "Omogući",
"Enable\/Disable forgot password feature.": "Omogući\/Onemogućo mogućnost obnove zaboravljene lozinke.",
"Enable\/Disable Google reCAPTCHA during the registration.": "Omogući\/Onemogući Google reCAPTCHA prilikom registracije.",
"Enable\/Disable Two-Factor Authentication for the application.": "Omogući\/Onemogući 2FA autentifikaciju.",
"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.": "Omogući/Onemogući protokol potvrde korisnika za aplikaciju. Kada je omogućen, korisnicima će trebati odobrenje administratora pre nego što postanu Aktivni.",
"Enabling...": "Uključivanje...",
"environment variable inside": "promenljivu okruženja u",
"environment variables inside": "promenljivu okruženja u",
"file": "datoteka",
"First Name": "Ime",
"Forgot Password": "Zaboravljena Lozinka",
"Forgot Your Password?": "Zaboravili ste lozinku?",
"from": "od",
"Full Name": "Puno Ime",
"General": "Opšte",
"General Settings": "Opšta Podešavanja",
"Google reCAPTCHA": "Google reCAPTCHA",
"Gravatar": "Gravatar",
"Hello!": "Zdravo!",
"I accept": "Prihvatam",
"I forgot my password": "Zaboravio sam lozinku",
"If you did not receive the email": "Ukoliko niste primili email",
"If you did not request a password reset, no further action is required.": "Ukoliko Vi niste zahtevali resetovanje vaše lozinke možete ignorisati ovaj mejl.",
"If you're having trouble clicking the \":button\" button, copy and paste the URL below into your web browser:": "Ukoliko ne možete da kliknete na dugme iznad, kopirajte sledeću adresu u polje za \n adresu Vašeg web pretraživača:",
"Impersonate": "Predstavi se kao on",
"Impersonate User": "Predstavi se kao korisnik",
"In order to enable Two-Factor Authentication you have to register and create new application on": "Da biste omogućili Two-Factor autentifikaciju, neophodno je da se registrujete i kreirate novu aplikaciju na",
"In order to enable Two-Factor Authentication, you must install": "Da biste omogućili Two-Factor autentifikaciju, neophodno je da instalirate",
"Invalidate Session": "Poništi Sesiju",
"IP Address": "IP Adresa",
"Last Activity": "Poslednja Aktivnost",
"Last Logged In": "Poslednje prijavljivanje",
"Last Name": "Prezime",
"Last Two Weeks": "Poslednje dve nedelje",
"Latest Registrations": "Poslednje Registracije",
"Leave field blank if you don't want to change it": "Ostavite prazno ukoliko ne želite da promenite",
"Lockout Time": "Period zaključanosti",
"Log In": "Uloguj se",
"Login": "Uloguj se",
"Login Details": "Podaci za prijavljivanje",
"Login details updated successfully.": "Podaci za prijavljivanje su uspešno ažurirani.",
"Logout": "Izloguj se",
"Maximum Number of Attempts": "Maksimalni broj pokušaja",
"Maximum number of incorrect login attempts before lockout.": "Maksimalni broj pogrešnih pokušaja logovanja pre nego što se izvrši zaključavanje naloga.",
"My Profile": "Moj Profil",
"My Sessions": "Moje Sesije",
"N\/A": "-",
"Name": "Ime",
"new": "novo",
"New Password": "Nova Lozinka",
"New User Registration": "Novi Korisnik",
"New user was just registered on :app website.": "Novi korisnik se upravo registrovao na :app web sajtu.",
"New Users": "Novi Korisnici",
"No Photo": "Bez Fotografije",
"No records found.": "Nema podataka.",
"Notification Settings": "Podešavanje Obaveštenja",
"Notifications": "Obaveštenja",
"Number of minutes that the reset token should be considered valid.": "Koliko se dugo token za reset lozinke može smatrati validnim (u minutima).",
"Number of minutes to lock the user out for after specified maximum number of incorrect login attempts.": "Koliko dugo će nalog ostati zaključan nakon maksimalnog broja neuspelih pokušaja logovanja (u minutima).",
"Only users who already created an account can log in.": "Samo korisnici koji su već kreirali nalog mogu da se prijave.",
"optional": "opciono",
"Password": "Lozinka",
"Password Reset Request": "Zahtev za promenu lozinke",
"Permission created successfully.": "Dozvola je uspešno kreirana.",
"Permission deleted successfully.": "Dozvola je uspešno obrisana.",
"Permission Details": "Detailji Dozvole",
"Permission Name": "Ime Dozvole",
"permission name": "ime dozvole",
"Permission updated successfully.": "Dozvola je uspešno ažurirana.",
"Permission with this name already exists.": "Dozvola sa ovim imenom već postoji.",
"Permissions": "Dozvole",
"Permissions saved successfully.": "Dozvole su uspešno sačuvane.",
"Phone": "Telefon",
"Phone Verification": "Verifikacija Telefona",
"Phone without country code": "Broj koji ne uključuje pozivni broj države",
"Please Confirm": "Molimo potvrdite",
"Please finish configuring Two-Factor authentication below.": "Molimo Vas završite konfigurisanje Two-Factor autentifikacije.",
"Please provide your email and pick a new password below.": "Molimo Vas da unesete Vašu email adresu i da odaberete novu lozinku.",
"Please provide your email below and we will send you a password reset link.": "Molimo Vas unesite Vaš email i mi ćemo Vam poslati link za promenu lozinke.",
"Profile updated successfully.": "Profil je uspešno ažuriran.",
"reCAPTCHA disabled successfully.": "reCAPTCHA je uspešno isključen.",
"reCAPTCHA enabled successfully.": "reCAPTCHA je uspešno uključen.",
"reCAPTCHA Website": "reCAPTCHA Web sajt",
"Regards": "Pozdrav",
"Register": "Kreiraj nalog",
"Registration": "Registracija",
"Registration Date": "Datum Registracije",
"Registration History": "Istorija Registracija",
"Remember me?": "Zapamti me?",
"Require email confirmation from your newly registered users.": "Zahtevaj potvrdu mejl adrese od za nove korisnike.",
"Resend Token": "Ponovo Pošalji Token",
"Reset Password": "Promeni Lozinku",
"Reset Token Lifetime": "Životni vek tokena",
"Reset Your Password": "Promenite Lozinku",
"Role": "Uloga",
"Role created successfully.": "Uloga je uspešno kreirana.",
"Role deleted successfully.": "Uloga je uspešno obrisana.",
"Role Details": "Detalji Uloge",
"Role Name": "Ime Uloge",
"Role updated successfully.": "Uloga je uspešno ažurirana.",
"Roles": "Uloge",
"Roles & Permissions": "Uloge i Dozvole",
"Save": "Sačuvaj",
"Save Permissions": "Sačuvaj dozvole",
"Search for users...": "Traži korisnika...",
"Secret Key": "Tajni ključ",
"Select a Country": "Odaberi državu",
"Send an email to the Administrators when user signs up.": "Pošalji email administratorima kada se registruje novi korisnik.",
"Sending...": "Slanje...",
"Session invalidated successfully.": "Sesija je uspešno poništena.",
"Sessions": "Sesije",
"Settings": "Podešavanja",
"Settings updated successfully.": "Podešavanja su uspešno ažurirana.",
"Should 'Remember Me' checkbox be displayed on login form?": "Prikaži 'Zapamti me' checkbox-a na formi za prijavljivanje?",
"Should the system throttle authentication attempts?": "Da li sistem treba da kontroliše \"propusnost\" prilikom autentifikacije?",
"Sign Up": "Kreiraj Nalog",
"Sign-Up Notification": "Obaveštenja za kreiranje naloga",
"Site Key": "Site Key",
"Something went wrong during the authentication process. Please try again.": "Došlo je do greške prilikom autentifikacije. Molimo Vas pokušajte ponovo.",
"Status": "Status",
"Stop Impersonating": "Prekini lažno predstavljanje",
"Terms & Conditions": "Uslovi korišćenja",
"Terms of Service": "Uslovi korišćenja",
"The user has to confirm that he agree with terms and conditions in order to create an account.": "Molimo Vas da potvrdite da se slažete sa uslovima korišćenja kako biste kreirali nalog.",
"The user is waiting on your approval to be able to access the website.": "Korisnik čeka odobrenje da bi mogao da pristupi aplikaciji.",
"This password reset link will expire in :count minutes.": "Link za promenu lozinke ističe za :count minuta.",
"Throttle Authentication": "Ograniči \"propusnost\"",
"To utilize Google reCAPTCHA, please get your": "Da biste aktivirali Google reCAPTCHA, molimo Vas pribavite Vaš",
"To view the user details just visit the link below.": "Za više detalja o korisniku posetite link ispod.",
"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.": "Da bi ste omogućili Two-Factor autentifikaciju, skenirajte QR kod pomoću Vaše aplikacije za autentifikaciju i unesite generisan OTP kod.",
"Token": "Token",
"Total Users": "Ukupno Korisnika",
"Two-Factor Authentication": "Two-Factor Autentifikacija",
"Two-Factor Authentication disabled successfully.": "Two-Factor autentifikacija je uspešno deaktivirana.",
"Two-Factor Authentication enabled successfully.": "Two-Factor autentifikacija je uspešno aktivirana.",
"Two-Factor Phone Verification": "Two-Factor Verifikacija Telefona",
"Unconfirmed Users": "Neverifikovani korisnici",
"Unknown": "Nepoznato",
"Update": "Ažuriraj",
"Update Details": "Ažuriraj Podatke",
"Update Password": "Ažuriraj Lozinku",
"Update Profile": "Ažuriraj Profil",
"Upload Photo": "Dodaj sliku",
"user": "korisnik",
"User Approval": "Odobrenje Korisnika",
"User created successfully.": "Korisnik je uspešno kreiran.",
"User deleted successfully.": "Korisnik je uspešno obrisan.",
"User Details": "Detalji o korisniku",
"User Sessions": "Sesije korisnika",
"User updated successfully.": "Korisnik je uspešno ažuriran.",
"Username": "Korisničko ime",
"users": "korisnici",
"Users": "Korisnici",
"User Confirmation Flow": "Potvrda korisnika",
"User Confirmation Flow enabled successfully.": "Potvrda korisnika uspešno aktivirana.",
"User Confirmation Flow disabled successfully.": "Potvrda korisnika uspešno deaktivirana.",
"Validate": "Proveri validnost",
"Verify": "Verifikuj",
"Verify Your Email Address": "Verifikuj email adresu",
"E-Mail verified successfully.": "Email adresa je uspešno verifikovana.",
"Verifying...": "Verifikacija...",
"View All": "Vidi sve",
"View User": "Vidi korisnika",
"Yes, delete him!": "Da, obriši!",
"Yes, delete it!": "Da, obriši!",
"Yes, proceed!": "Da, nastavi!",
"You are receiving this email because we received a password reset request for your account.": "Dobili ste ovu poruku zbog zahteva za promenu lozinke koji smo primili od Vašeg naloga.",
"You cannot delete yourself.": "Ne možete da obrišete svoj nalog.",
"You have to accept Terms of Service.": "Morate da prihvatite uslove korišćenja.",
"You have to provide your email address.": "Morate da omogućite pristup Vašoj mejl adresi.",
"You may retrieve this token from your phone's Authenticator application.": "Možete da preuzmete ovaj token iz Vaše Authenticator aplikacije.",
"Your account is banned by administrator.": "Vaš nalog je blokiran od strane administratora.",
"Your account is created successfully! Please confirm your email.": "Vaš nalog je uspešno kreiran. Molimo Vas da potvrdite Vašu mejl adresu.",
"Your account is created successfully!": "Vaš nalog je uspešno kreiran.",
"Your E-Mail": "Email Adresa",
"Your account has been approved by administrators. You can now log in now and start using the application.": "Vaš nalog je odobren od strane administratora. Sada se možete prijaviti i koristiti aplikaciju.",
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication.": "Kada je Two-Factor uključen, od Vas će biti zatraženo da unesete sigurnosni kod tokom autentifikacije.",
"Max Number of Active Sessions": "Maksimalni broj aktivnih sesija",
"Maximum number of active sessions per user. Set to 0 to allow unlimited number of active sessions.": "Maksimalni broj aktivnih sesija po korisniku. Postavite na 0 da biste omogućili neograničen broj sesija.",
"Only applies when using database session driver.": "Primenljivo je samo kada se koristi baza podataka za čuvanje sesija.",
"Code": "Kod",
"Confirm": "Potvrdi",
"Force Password Change On Next Login": "Prisilite promenu lozinke pri sledećoj prijavi",
"Force all the users created manually by admin to reset the password on their first log in to be able to use the application.": "Prisilite sve korisnike koje je administrator kreirao ručno da resetuju lozinku prilikom prvog prijavljivanja da bi mogli da koriste aplikaciju.",
"Force Password Change for users enabled successfully.": "Prisilna promena lozinke za korisnike je uspešno aktivirana.",
"Force Password Change for users disabled successfully.": "Prisilna promena lozinke za korisnike je uspešno deaktivirana.",
"There are <strong>:count</strong> user(s) waiting on your approval to be able to access the application.": "<strong>:count</strong> korisnik(a) čeka na odobrenje naloga.",
"Review": "Pogledaj",
"Are you sure that you want to approve this user?": "Da li ste sigurni da želite da odobrite ovog korisnika?",
"Force Password Change on next login": "Zahtevaj od korisnika da promeni lozinku pri sledećem prijavljivanju",
"This action is disabled in the demo.": "Ova akcija je onemogućena u demo verziji.",
"The demo account cannot be modified.": "Demo nalog ne može biti izmenjen.",
"Default roles and permissions cannot be modified in the demo.": "Podrazumevane uloge i dozvole ne mogu biti izmenjene u demo verziji."
}
+25
View File
@@ -0,0 +1,25 @@
<?php
return [
'months' => [
1 => 'Januar',
2 => 'Februar',
3 => 'Mart',
4 => 'April',
5 => 'Maj',
6 => 'Jun',
7 => 'Jul',
8 => 'Avgust',
9 => 'Septembar',
10 => 'Oktobar',
11 => 'Novembar',
12 => 'Decembar',
],
'status' => [
'Active' => 'Aktivan',
'Banned' => 'Banovan',
'Unconfirmed' => 'Neverifikovan',
'Waiting Approval' => 'Čeka Odobrenje',
],
];
+32
View File
@@ -0,0 +1,32 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Podaci ne odgovaraju ni jednom nalogu.',
'throttle' => 'Previše neuspelih pokušaja. Pokušajte ponovo za :seconds sekundi.',
'banned' => 'Vaš nalog je blokiran od strane administratora.',
'approval' => 'Vaš nalog čeka odobrenje od administratora.',
'max_sessions_reached' => 'Dostigli ste maksimalni dozvoljeni broj aktivnih sesija. Odjavite se na drugim uređajima i pokušajte ponovo.',
'2fa' => [
'enabled_successfully' => 'Two-Factor autentifikacija je uspešno aktivirana.',
'disabled_successfully' => 'Two-Factor autentifikacija je uspešno deaktivirana.',
'already_enabled' => 'Two-Factor autentifikacija je već uključena za ovog korisnika.',
'not_enabled' => 'Two-Factor autentifikacija nije uključena za ovog korisnika.',
'phone_in_use' => 'Korisnik sa navedenim telefonskim brojem i pozivnim brojem države već postoji.',
'invalid_token' => '2FA token nije validan.',
'token_sent' => 'Token za verifikaciju je uspešno poslat.',
],
];
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Nazad',
'next' => 'Napred &raquo;',
];
+22
View File
@@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reminder Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'password' => 'Lozinka mora imati najmanje 8 karaktera i da odgovara potvrdi.',
'reset' => 'Lozinka je resetovana!',
'sent' => 'Poslali smo reset link za vašu lozinku!',
'token' => 'Ukucana oznaka za resetovanje lozinke nije važeća.',
'user' => 'Nismo uspeli pronaći korisnika sa email adresom.',
];
+119
View File
@@ -0,0 +1,119 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'Polje :attribute mora biti prihvaćeno.',
'active_url' => 'Polje :attribute nije validan URL.',
'after' => 'Polje :attribute mora biti datum posle :date.',
'alpha' => 'Polje :attribute može sadržati samo slova.',
'alpha_dash' => 'Polje :attribute može sadržati samo slova, brojeve i povlake.',
'alpha_num' => 'Polje :attribute može sadržati samo slova i brojeve.',
'array' => 'Polje :attribute mora sadržati nekih niz stavki.',
'before' => 'Polje :attribute mora biti datum pre :date.',
'between' => [
'numeric' => 'Polje :attribute mora biti između :min - :max.',
'file' => 'Fajl :attribute mora biti između :min - :max kilobajta.',
'string' => 'Polje :attribute mora biti između :min - :max karaktera.',
'array' => 'Polje :attribute mora biti između :min - :max stavki.',
],
'boolean' => 'Polje :attribute mora biti tačno ili netačno',
'confirmed' => 'Potvrda polja :attribute se ne poklapa.',
'date' => 'Polje :attribute nije važeći datum.',
'date_format' => 'Polje :attribute ne odgovora prema formatu :format.',
'different' => 'Polja :attribute i :other moraju biti različita.',
'digits' => 'Polje :attribute mora sadržati :digits šifri.',
'digits_between' => 'Polje :attribute mora biti izemđu :min i :max šifri.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'Format polja :attribute nije validan.',
'exists' => 'Odabrano polje :attribute nije validno.',
'filled' => 'Polje :attribute je obavezno.',
'image' => 'Polje :attribute mora biti slika.',
'in' => 'Odabrano polje :attribute nije validno.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'Polje :attribute mora biti broj.',
'ip' => 'Polje :attribute mora biti validna IP adresa.',
'json' => 'The :attribute must be a valid JSON string.',
'max' => [
'numeric' => 'Polje :attribute mora biti manje od :max.',
'file' => 'Polje :attribute mora biti manje od :max kilobajta.',
'string' => 'Polje :attribute mora sadržati manje od :max karaktera.',
'array' => 'Polje :attribute ne smije da image više od :max stavki.',
],
'mimes' => 'Polje :attribute mora biti fajl tipa: :values.',
'min' => [
'numeric' => 'Polje :attribute mora biti najmanje :min.',
'file' => 'Fajl :attribute mora biti najmanje :min kilobajta.',
'string' => 'Polje :attribute mora sadržati najmanje :min karaktera.',
'array' => 'Polje :attribute mora sadrzati najmanje :min stavku.',
],
'not_in' => 'Odabrani element polja :attribute nije validan.',
'numeric' => 'Polje :attribute mora biti broj.',
'present' => 'The :attribute field must be present.',
'regex' => 'Format polja :attribute nije validan.',
'required' => 'Polje :attribute je obavezno.',
'required_if' => 'Polje :attribute je potrebno kada polje :other sadrži :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'Polje :attribute je potrebno kada polje :values je prisutan.',
'required_with_all' => 'Polje :attribute je obavezno kada je :values prikazano.',
'required_without' => 'Polje :attribute je potrebno kada polje :values nije prisutan.',
'required_without_all' => 'Polje :attribute je potrebno kada nijedan od sledeći polja :values nisu prisutni.',
'same' => 'Polja :attribute i :other se moraju poklapati.',
'size' => [
'numeric' => 'Polje :attribute mora biti :size.',
'file' => 'Fajl :attribute mora biti :size kilobajta.',
'string' => 'Polje :attribute mora biti :size karaktera.',
'array' => 'Polje :attribute mora sadržati :size stavki.',
],
'string' => 'Polje :attribute mora sadržati slova.',
'timezone' => 'Polje :attribute mora biti ispravna vremenska zona.',
'unique' => 'Polje :attribute već postoji.',
'url' => 'Format polja :attribute ne važi.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'username' => [
'unique' => 'Korisničko ime je zauzeto.',
],
'email' => [
'unique' => 'Uneta e-adresa već postoji.',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap attribute place-holders
| with something more reader friendly such as E-Mail Address instead
| of "email". This simply helps us make messages a little cleaner.
|
*/
'attributes' => [
'username' => 'korisničko ime',
'password' => 'lozinka',
],
];
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title inertia>{{ setting('app_name') }}</title>
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="{{ url('assets/img/icons/apple-touch-icon-144x144.png') }}" />
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="{{ url('assets/img/icons/apple-touch-icon-152x152.png') }}" />
<link rel="icon" type="image/png" href="{{ url('assets/img/icons/favicon-32x32.png') }}" sizes="32x32" />
<link rel="icon" type="image/png" href="{{ url('assets/img/icons/favicon-16x16.png') }}" sizes="16x16" />
<meta name="application-name" content="{{ setting('app_name') }}"/>
<meta name="msapplication-TileColor" content="#FFFFFF" />
<meta name="msapplication-TileImage" content="{{ url('assets/img/icons/mstile-144x144.png') }}" />
{{-- Icon font. Kept as a static asset so plugin authors can keep using
Item::icon('fas fa-server') without pulling Font Awesome through npm. --}}
<link media="all" type="text/css" rel="stylesheet" href="{{ url('assets/css/fontawesome-all.min.css') }}">
{{--
Registration queue for plugin bundles. Installed before anything else so
a plugin script may call Vanguard.plugin() whether it loads before or
after the app bundle; resources/js/vanguard.ts drains the queue on boot.
--}}
<script>
window.Vanguard = window.Vanguard || { _q: [] };
window.Vanguard.plugin = window.Vanguard.plugin || function () {
window.Vanguard._q.push(arguments);
};
</script>
@foreach (\Vanguard\Plugins\Vanguard::assets() as $asset)
@if (! empty($asset['css']))
<link media="all" type="text/css" rel="stylesheet" href="{{ $asset['css'] }}">
@endif
@endforeach
{{--
Ziggy's route list, emitted once per full page load as window.Ziggy.
The ZiggyVue plugin reads it from there, so the ~10KB payload does not
have to ride along with every Inertia navigation as a shared prop.
--}}
@routes
@vite('resources/js/app.ts')
@inertiaHead
</head>
<body>
@inertia
@foreach (\Vanguard\Plugins\Vanguard::assets() as $asset)
@if (! empty($asset['js']))
<script type="module" src="{{ $asset['js'] }}"></script>
@endif
@endforeach
</body>
</html>
@@ -0,0 +1,14 @@
@props(['href' => null, 'disabled' => false])
@php
$classes = 'inline-flex cursor-pointer items-center gap-2 rounded-md bg-primary-600 px-4 py-2 font-medium'
.' text-white transition-colors hover:bg-primary-700 focus-visible:outline-2'
.' focus-visible:outline-offset-2 focus-visible:outline-primary-600'
.' disabled:pointer-events-none disabled:opacity-60';
@endphp
@if ($href && ! $disabled)
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
@else
<button {{ $attributes->merge(['class' => $classes]) }} @disabled($disabled)>{{ $slot }}</button>
@endif
@@ -0,0 +1,7 @@
@props(['title'])
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="mb-4 border-b border-slate-100 pb-4 text-xl font-medium text-slate-800">{{ $title }}</h3>
{{ $slot }}
</div>
@@ -0,0 +1,23 @@
@props(['name', 'label', 'type' => 'text', 'hint' => null, 'value' => ''])
<div>
<label for="{{ $name }}" class="mb-1 block text-slate-700">{{ $label }}</label>
<input
type="{{ $type }}"
id="{{ $name }}"
name="{{ $name }}"
{{-- Passwords are never echoed back into the form. --}}
value="{{ $type === 'password' ? '' : old($name, $value) }}"
@class([
'bg-field block w-full rounded-md border px-3 py-2 text-slate-700 shadow-xs',
'transition-colors focus:outline-none focus:ring-2',
'border-red-500 focus:border-red-500 focus:ring-red-100' => $errors->has($name),
'border-slate-300 focus:border-primary-500 focus:ring-primary-100' => ! $errors->has($name),
])
>
<small @class(['mt-1 block', 'text-red-600' => $errors->has($name), 'text-slate-500' => ! $errors->has($name)])>
{{ $errors->first($name) ?: $hint }}
</small>
</div>
@@ -0,0 +1,4 @@
<img src="{{ $src ?? '' }}"
class="{{ $class ?? '' }}"
alt="{{ setting('app_name') }}"
height="{{ $height ?? '50' }}">

Some files were not shown because too many files have changed in this diff Show More