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