Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and Docker Compose setup for local and production deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
107
resources/js/App.vue
Normal file
107
resources/js/App.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useTheme } from './composables/useTheme';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const showNav = computed(() => auth.isAuthenticated && route.name !== 'oauth-authorize');
|
||||
const currentLocale = window.__SESSION_LOCALE__ ?? 'es';
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await auth.logout();
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
async function setLocale(locale: string): Promise<void> {
|
||||
await axios.post('/web-api/locale', { locale });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function onLocaleChange(event: Event): void {
|
||||
setLocale((event.target as HTMLSelectElement).value);
|
||||
}
|
||||
|
||||
interface NavLink {
|
||||
to: string;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const navLinks = computed<NavLink[]>(() => [
|
||||
{ to: '/', name: 'dashboard', label: t('nav.dashboard') },
|
||||
{ to: '/clients', name: 'clients', label: t('nav.clients') },
|
||||
{ to: '/tokens', name: 'tokens', label: t('nav.tokens') },
|
||||
{ to: '/authorized-apps', name: 'authorized-apps', label: t('nav.authorizedApps') },
|
||||
...(auth.isAdmin ? [{ to: '/admin/users', name: 'admin-users', label: t('nav.users') }] : []),
|
||||
...(auth.isAdmin ? [{ to: '/admin/analytics', name: 'admin-analytics', label: t('nav.analytics') }] : []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<nav v-if="showNav" class="app-nav">
|
||||
<div class="app-nav__inner">
|
||||
<div class="app-nav__section">
|
||||
<router-link to="/" class="app-nav__brand">
|
||||
<span class="app-nav__logo">O2</span>
|
||||
<span class="app-nav__brand-name">OAuth2 Server</span>
|
||||
</router-link>
|
||||
|
||||
<div class="app-nav__links">
|
||||
<router-link
|
||||
v-for="link in navLinks"
|
||||
:key="link.name"
|
||||
:to="link.to"
|
||||
class="app-nav__link"
|
||||
active-class="app-nav__link--active"
|
||||
>
|
||||
{{ link.label }}
|
||||
</router-link>
|
||||
<a href="/api/documentation" class="app-nav__link">{{ t('nav.apiDocs') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-nav__controls">
|
||||
<select :value="currentLocale" @change="onLocaleChange" class="pill-select">
|
||||
<option value="es">ES</option>
|
||||
<option value="en">EN</option>
|
||||
<option value="ca">CA</option>
|
||||
</select>
|
||||
|
||||
<button @click="toggleTheme" type="button" class="app-nav__theme-toggle"
|
||||
:aria-label="isDark ? 'Light mode' : 'Dark mode'">
|
||||
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.415 1.414a.75.75 0 101.06 1.06l1.415-1.414zM21.75 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.414-1.415a.75.75 0 10-1.06 1.06l1.414 1.415zM12 18a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0v-2A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.414 1.414a.75.75 0 001.06 1.06l1.415-1.414zM6 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06L6.343 5.283a.75.75 0 10-1.06 1.06l1.414 1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="app-nav__user">{{ auth.user?.email }}</span>
|
||||
<button @click="logout" class="btn btn--invert">
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main :class="showNav ? 'page' : ''">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
13
resources/js/app.ts
Normal file
13
resources/js/app.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import './bootstrap';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import i18n from './i18n';
|
||||
|
||||
createApp(App)
|
||||
.use(createPinia())
|
||||
.use(router)
|
||||
.use(i18n)
|
||||
.mount('#app');
|
||||
13
resources/js/bootstrap.ts
Normal file
13
resources/js/bootstrap.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import axios from 'axios';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
axios: typeof axios;
|
||||
}
|
||||
}
|
||||
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
window.axios.defaults.withCredentials = true;
|
||||
window.axios.defaults.withXSRFToken = true;
|
||||
45
resources/js/components/charts/BarList.vue
Normal file
45
resources/js/components/charts/BarList.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface BarItem {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
items: BarItem[];
|
||||
emptyText?: string;
|
||||
}>(),
|
||||
{
|
||||
emptyText: 'Sin datos en este período.',
|
||||
},
|
||||
);
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((i) => i.value)));
|
||||
|
||||
function widthPct(value: number): number {
|
||||
return Math.max(2, Math.round((value / max.value) * 100));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-list">
|
||||
<h3 class="bar-list__title">{{ title }}</h3>
|
||||
|
||||
<p v-if="!items.length" class="bar-list__empty">{{ emptyText }}</p>
|
||||
|
||||
<ul v-else class="bar-list__items">
|
||||
<li v-for="item in items" :key="item.label" class="bar-list__row" :title="`${item.label}: ${item.value}`">
|
||||
<div class="bar-list__row-head">
|
||||
<span class="bar-list__label">{{ item.label }}</span>
|
||||
<span class="bar-list__value">{{ item.value }}</span>
|
||||
</div>
|
||||
<div class="bar-list__track">
|
||||
<div class="bar-list__fill" :style="{ width: widthPct(item.value) + '%' }" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
328
resources/js/components/charts/DailyLoginsChart.vue
Normal file
328
resources/js/components/charts/DailyLoginsChart.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
interface DailyPoint {
|
||||
date: string;
|
||||
session: number;
|
||||
oauth: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: DailyPoint[];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const width = 720;
|
||||
const height = 220;
|
||||
const padLeft = 34;
|
||||
const padBottom = 22;
|
||||
const padTop = 10;
|
||||
const plotW = width - padLeft - 8;
|
||||
const plotH = height - padTop - padBottom;
|
||||
|
||||
const maxTotal = computed(() => Math.max(4, ...props.data.map((d) => d.session + d.oauth)));
|
||||
|
||||
const niceMax = computed(() => {
|
||||
// redondea el techo del eje Y a un número "lindo"
|
||||
const raw = maxTotal.value;
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(raw || 1)));
|
||||
const step = Math.ceil(raw / magnitude) * magnitude;
|
||||
|
||||
return step <= 0 ? 4 : step;
|
||||
});
|
||||
|
||||
const yTicks = computed(() => [0, 0.5, 1].map((f) => Math.round(niceMax.value * f)));
|
||||
|
||||
const barW = computed(() => Math.max(2, plotW / props.data.length - 2));
|
||||
|
||||
function x(i: number): number {
|
||||
return padLeft + (i * plotW) / props.data.length;
|
||||
}
|
||||
function yFor(value: number): number {
|
||||
return padTop + plotH - (value / niceMax.value) * plotH;
|
||||
}
|
||||
|
||||
// Rect con SOLO las puntas de arriba redondeadas (4px) y la base cuadrada,
|
||||
// para que el extremo libre del stack se redondee pero la base (donde se
|
||||
// apoya en el baseline, o en el segmento de abajo) quede a escuadra.
|
||||
function topRoundedRect(xPos: number, y: number, w: number, h: number): string {
|
||||
const r = Math.min(4, w / 2, Math.max(0, h));
|
||||
if (h <= 0) return '';
|
||||
if (r <= 0) return `M${xPos},${y} h${w} v${h} h${-w} Z`;
|
||||
|
||||
return [
|
||||
`M${xPos},${y + h}`,
|
||||
`V${y + r}`,
|
||||
`Q${xPos},${y} ${xPos + r},${y}`,
|
||||
`H${xPos + w - r}`,
|
||||
`Q${xPos + w},${y} ${xPos + w},${y + r}`,
|
||||
`V${y + h}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
const hovered = ref<number | null>(null);
|
||||
|
||||
function labelFor(i: number): string {
|
||||
const d = props.data[i];
|
||||
if (!d) return '';
|
||||
const dt = new Date(d.date + 'T00:00:00');
|
||||
|
||||
return dt.toLocaleDateString(locale.value, { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
function ariaLabelFor(i: number): string {
|
||||
const d = props.data[i];
|
||||
if (!d) return '';
|
||||
|
||||
return t('analytics.barAriaLabel', { date: labelFor(i), oauth: d.oauth, session: d.session });
|
||||
}
|
||||
|
||||
const showEveryNth = computed(() => Math.ceil(props.data.length / 6));
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (hovered.value === null) return { display: 'none' };
|
||||
const left = x(hovered.value) + barW.value / 2;
|
||||
const clampedLeft = Math.min(Math.max(left, 70), width - 70);
|
||||
|
||||
return {
|
||||
left: (clampedLeft / width) * 100 + '%',
|
||||
top: '4px',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-chart">
|
||||
<div class="daily-chart__header">
|
||||
<h3 class="daily-chart__title">{{ t('analytics.loginsByDay') }}</h3>
|
||||
<div class="daily-chart__legend">
|
||||
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--oauth"></span> {{ t('analytics.appsOAuth') }}</span>
|
||||
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--session"></span> {{ t('analytics.panelAdmin') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="daily-chart__body">
|
||||
<svg :viewBox="`0 0 ${width} ${height}`" class="daily-chart__svg" role="img" :aria-label="t('analytics.chartAriaLabel')">
|
||||
<!-- gridlines -->
|
||||
<g>
|
||||
<line
|
||||
v-for="tick in yTicks"
|
||||
:key="tick"
|
||||
:x1="padLeft"
|
||||
:x2="width - 4"
|
||||
:y1="yFor(tick)"
|
||||
:y2="yFor(tick)"
|
||||
class="daily-chart__gridline"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text v-for="tick in yTicks" :key="'l' + tick" :x="padLeft - 6" :y="yFor(tick) + 3" text-anchor="end" class="daily-chart__axis-text" font-size="9">
|
||||
{{ tick }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- bars -->
|
||||
<g v-for="(d, i) in data" :key="d.date">
|
||||
<!-- oauth segment (bottom, apoyado en el baseline -> cuadrado).
|
||||
Si no hay segmento de sesión arriba, esta punta es la libre: se redondea. -->
|
||||
<path
|
||||
v-if="d.session > 0"
|
||||
:d="`M${x(i)},${yFor(0)} h${barW} v${-(Math.max(0, yFor(0) - yFor(d.oauth)))} h${-barW} Z`"
|
||||
class="daily-chart__bar daily-chart__bar--oauth"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
:d="topRoundedRect(x(i), yFor(d.oauth), barW, Math.max(0, yFor(0) - yFor(d.oauth)))"
|
||||
class="daily-chart__bar daily-chart__bar--oauth"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<!-- session segment (stacked on top, 2px surface gap, punta libre redondeada) -->
|
||||
<path
|
||||
v-if="d.session > 0"
|
||||
:d="topRoundedRect(x(i), yFor(d.oauth + d.session), barW, Math.max(0, yFor(d.oauth) - yFor(d.oauth + d.session) - 2))"
|
||||
class="daily-chart__bar daily-chart__bar--session"
|
||||
:opacity="hovered === null || hovered === i ? 1 : 0.35"
|
||||
/>
|
||||
<!-- hit target: toda la columna, más ancho que la barra visible -->
|
||||
<rect
|
||||
:x="x(i) - 1"
|
||||
:y="padTop"
|
||||
:width="barW + 2"
|
||||
:height="plotH"
|
||||
class="daily-chart__hit"
|
||||
@mouseenter="hovered = i"
|
||||
@mouseleave="hovered = null"
|
||||
@focus="hovered = i"
|
||||
@blur="hovered = null"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabelFor(i)"
|
||||
/>
|
||||
<text
|
||||
v-if="i % showEveryNth === 0"
|
||||
:x="x(i) + barW / 2"
|
||||
:y="height - 4"
|
||||
text-anchor="middle"
|
||||
class="daily-chart__axis-text"
|
||||
font-size="9"
|
||||
>
|
||||
{{ labelFor(i) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- baseline -->
|
||||
<line :x1="padLeft" :x2="width - 4" :y1="yFor(0)" :y2="yFor(0)" class="daily-chart__baseline" stroke-width="1" />
|
||||
</svg>
|
||||
|
||||
<div v-if="hovered !== null" class="daily-chart__tooltip" :style="tooltipStyle">
|
||||
<p class="daily-chart__tooltip-date">{{ labelFor(hovered) }}</p>
|
||||
<p class="daily-chart__tooltip-row">
|
||||
<span class="daily-chart__swatch daily-chart__swatch--oauth daily-chart__swatch--sm"></span>
|
||||
{{ t('analytics.tooltipAppLabel') }} <strong>{{ data[hovered].oauth }}</strong>
|
||||
</p>
|
||||
<p class="daily-chart__tooltip-row">
|
||||
<span class="daily-chart__swatch daily-chart__swatch--session daily-chart__swatch--sm"></span>
|
||||
{{ t('analytics.tooltipSessionLabel') }} <strong>{{ data[hovered].session }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Este componente usa <style scoped>, así que el mixin "dark" global (pensado
|
||||
// para partials no-scoped) no aplica acá: hace falta ":global(.dark) &"
|
||||
// directo para atravesar el compilador scoped de Vue.
|
||||
.daily-chart {
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
|
||||
:global(.dark) & {
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--gridline: #2c2c2a;
|
||||
--axis: #383835;
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.daily-chart__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.daily-chart__legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.daily-chart__legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.daily-chart__swatch {
|
||||
display: inline-block;
|
||||
height: 0.5rem;
|
||||
width: 0.75rem;
|
||||
border-radius: 0.125rem;
|
||||
|
||||
&--oauth {
|
||||
background: var(--series-1);
|
||||
}
|
||||
|
||||
&--session {
|
||||
background: var(--series-2);
|
||||
}
|
||||
|
||||
&--sm {
|
||||
height: 0.5rem;
|
||||
width: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__body {
|
||||
position: relative;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.daily-chart__svg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.daily-chart__gridline {
|
||||
stroke: var(--gridline);
|
||||
}
|
||||
|
||||
.daily-chart__baseline {
|
||||
stroke: var(--axis);
|
||||
}
|
||||
|
||||
.daily-chart__axis-text {
|
||||
fill: var(--muted);
|
||||
}
|
||||
|
||||
.daily-chart__bar {
|
||||
&--oauth {
|
||||
fill: var(--series-1);
|
||||
}
|
||||
|
||||
&--session {
|
||||
fill: var(--series-2);
|
||||
}
|
||||
}
|
||||
|
||||
.daily-chart__hit {
|
||||
fill: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daily-chart__tooltip {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.daily-chart__tooltip-date {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.daily-chart__tooltip-row {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
strong {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
resources/js/components/charts/StatTile.vue
Normal file
15
resources/js/components/charts/StatTile.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-tile">
|
||||
<p class="stat-tile__label">{{ label }}</p>
|
||||
<p class="stat-tile__value">{{ value }}</p>
|
||||
<p v-if="hint" class="stat-tile__hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
16
resources/js/composables/useTheme.ts
Normal file
16
resources/js/composables/useTheme.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
// El estado inicial ya lo aplicó el script inline en app.blade.php (evita el
|
||||
// flash); acá solo reflejamos esa clase para que el ícono del toggle arranque
|
||||
// sincronizado.
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'));
|
||||
|
||||
function toggleTheme(): void {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return { isDark, toggleTheme };
|
||||
}
|
||||
209
resources/js/i18n/ca.ts
Normal file
209
resources/js/i18n/ca.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { MessageSchema } from './es';
|
||||
|
||||
const ca: MessageSchema = {
|
||||
nav: {
|
||||
dashboard: 'Tauler',
|
||||
clients: 'Clients OAuth',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Apps autoritzades',
|
||||
users: 'Usuaris',
|
||||
analytics: 'Analítiques',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Sortir',
|
||||
},
|
||||
common: {
|
||||
loading: 'Carregant…',
|
||||
creating: 'Creant…',
|
||||
create: 'Crear',
|
||||
save: 'Desar',
|
||||
cancel: 'Cancel·lar',
|
||||
close: 'Tancar',
|
||||
edit: 'Editar',
|
||||
delete: 'Esborrar',
|
||||
none: '(cap)',
|
||||
},
|
||||
login: {
|
||||
title: 'Entrar',
|
||||
subtitle: "Servidor OAuth2 — tauler d'administració.",
|
||||
email: 'Email',
|
||||
password: 'Contrasenya',
|
||||
submit: 'Entrar',
|
||||
submitting: 'Entrant…',
|
||||
noAccount: 'No tens compte?',
|
||||
signUp: "Registra't",
|
||||
invalidCredentials: 'Credencials no vàlides.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear compte',
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
password: 'Contrasenya',
|
||||
passwordConfirm: 'Confirmar contrasenya',
|
||||
submit: 'Crear compte',
|
||||
submitFirstUser: 'Crear compte d\'administrador',
|
||||
submitting: 'Creant…',
|
||||
haveAccount: 'Ja tens compte?',
|
||||
signIn: 'Entra',
|
||||
firstUserBanner: 'Encara no hi ha cap usuari. Aquest compte quedarà com a administrador, amb accés immediat (sense aprovació).',
|
||||
pendingTitle: 'Compte creat',
|
||||
pendingBody: "Un administrador ha d'aprovar el teu compte abans que puguis entrar. És possible que t'avisin fora de l'app quan estigui llest.",
|
||||
backToLogin: 'Tornar a entrar',
|
||||
},
|
||||
dashboard: {
|
||||
greeting: 'Hola, {name}',
|
||||
subtitle: "Aquest és el tauler d'administració del servidor OAuth2.",
|
||||
clientsTitle: 'Clients OAuth',
|
||||
clientsDesc: 'Registra apps (Authorization Code+PKCE, Client Credentials o Password Grant).',
|
||||
tokensTitle: 'Personal Access Tokens',
|
||||
tokensDesc: "Genera tokens d'accés directe a l'API, com API keys.",
|
||||
appsTitle: 'Apps autoritzades',
|
||||
appsDesc: 'Apps de tercers a les quals has donat accés al teu compte.',
|
||||
usersTitle: 'Usuaris',
|
||||
usersDesc: 'Alta, edició de rol/estat i baixa de comptes (només admins).',
|
||||
analyticsTitle: 'Analítiques',
|
||||
analyticsDesc: 'Sessions actives, logins per app i usuaris més actius (només admins).',
|
||||
apiTitle: 'API REST',
|
||||
apiDesc: 'Aquest servidor exposa el recurs {resource} (scopes {read}/{write}) perquè aplicacions de tercers gestionin usuaris via OAuth2. Requereix un access token de Passport. Documentació interactiva a {link}.',
|
||||
},
|
||||
clients: {
|
||||
title: 'Clients OAuth',
|
||||
subtitle: 'Apps registrades que poden demanar tokens a aquest servidor.',
|
||||
secretWarning: 'Desa aquest client secret ara, no el podràs tornar a veure:',
|
||||
clientIdLabel: 'client_id:',
|
||||
clientSecretLabel: 'client_secret:',
|
||||
newClient: 'Nou client',
|
||||
name: 'Nom',
|
||||
grantType: 'Grant type',
|
||||
grantAuthCode: 'Authorization Code + PKCE',
|
||||
grantClientCredentials: 'Client Credentials',
|
||||
grantPassword: 'Password Grant',
|
||||
redirectUris: 'Redirect URIs (separades per coma)',
|
||||
confidentialLabel: "Client confidencial (amb secret). Desmarca-ho per a un client públic que només faci servir PKCE.",
|
||||
create: 'Crear client',
|
||||
empty: 'Encara no has creat cap client.',
|
||||
revoke: 'Revocar',
|
||||
revokeConfirm: 'Vols revocar el client "{name}"? Els seus tokens deixaran de funcionar.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
subtitle: "Tokens d'accés directe a l'API, sense passar pel flux d'authorize (tipus API key).",
|
||||
tokenWarning: 'Desa aquest token ara, no el podràs tornar a veure:',
|
||||
newToken: 'Nou token',
|
||||
name: 'Nom',
|
||||
namePlaceholder: 'El meu script',
|
||||
scopes: 'Scopes',
|
||||
create: 'Crear token',
|
||||
empty: 'Encara no has creat cap token.',
|
||||
revoke: 'Revocar',
|
||||
revokeConfirm: 'Vols revocar el token "{name}"?',
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorizedApps: {
|
||||
title: 'Apps autoritzades',
|
||||
subtitle: 'Aplicacions de tercers a les quals has donat accés al teu compte.',
|
||||
empty: 'Encara no has autoritzat cap app.',
|
||||
revoke: 'Revocar accés',
|
||||
revokeConfirm: "Vols revocar l'accés de \"{name}\"?",
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorize: {
|
||||
title: 'Autoritzar aplicació',
|
||||
wantsAccess: '{client} vol accedir al teu compte ({email}) amb els següents permisos:',
|
||||
noScopes: "No sol·licita permisos addicionals (accés bàsic).",
|
||||
approve: 'Autoritzar',
|
||||
cancel: 'Cancel·lar',
|
||||
notFoundTitle: 'Sol·licitud no trobada',
|
||||
notFoundBody: "Aquesta pantalla només funciona quan hi arribes redirigit per una aplicació client via {path}. Torna-ho a provar des d'aquella app.",
|
||||
goDashboard: 'Anar al tauler',
|
||||
},
|
||||
adminUsers: {
|
||||
title: 'Usuaris',
|
||||
subtitle: 'Alta, edició de rol/estat i baixa de comptes.',
|
||||
newUser: 'Nou usuari',
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
password: 'Contrasenya',
|
||||
role: 'Rol',
|
||||
roleUser: 'Usuari',
|
||||
roleAdmin: 'Admin',
|
||||
create: 'Crear usuari',
|
||||
colName: 'Nom',
|
||||
colEmail: 'Email',
|
||||
colRole: 'Rol',
|
||||
colStatus: 'Estat',
|
||||
active: 'Actiu',
|
||||
inactive: 'Desactivat',
|
||||
noRole: '(sense rol)',
|
||||
edit: 'Editar',
|
||||
delete: 'Esborrar',
|
||||
deleteConfirm: 'Vols esborrar l\'usuari "{name}"? Anirà a la paperera i es pot restaurar.',
|
||||
deleteFailed: "No s'ha pogut esborrar l'usuari.",
|
||||
newPassword: 'Nova contrasenya',
|
||||
noChange: '(sense canvis)',
|
||||
save: 'Desar',
|
||||
cancel: 'Cancel·lar',
|
||||
appRolesTitle: 'Rols per app',
|
||||
appRolesDesc: 'El rol global de dalt mana en aquest panell. Aquí es defineix quin rol té {name} dins de cada app quan aquella app demana el seu usuari per API.',
|
||||
noAppRoles: 'Encara no hi ha rols assignats en cap app.',
|
||||
chooseApp: 'Tria una app…',
|
||||
assign: 'Assignar',
|
||||
assignFailed: "No s'ha pogut assignar el rol.",
|
||||
trashTitle: 'Paperera',
|
||||
viewTrash: 'Veure paperera',
|
||||
viewActive: 'Veure actius',
|
||||
trashEmpty: 'No hi ha usuaris esborrats.',
|
||||
deletedAt: 'Esborrat el {date}',
|
||||
restore: 'Restaurar',
|
||||
restoreFailed: "No s'ha pogut restaurar l'usuari.",
|
||||
forceDelete: 'Esborrar definitivament',
|
||||
forceDeleteConfirm: 'Vols esborrar definitivament "{name}"? Aquesta acció no es pot desfer.',
|
||||
forceDeleteFailed: "No s'ha pogut esborrar definitivament.",
|
||||
pendingTitle: 'Pendents',
|
||||
viewPending: 'Pendents',
|
||||
pendingEmpty: "No hi ha registres pendents d'aprovació.",
|
||||
registeredAt: 'Es va registrar el {date}',
|
||||
approve: 'Aprovar',
|
||||
approveFailed: "No s'ha pogut aprovar l'usuari.",
|
||||
reject: 'Rebutjar',
|
||||
rejectConfirm: 'Vols rebutjar el registre de "{name}"? Anirà a la paperera i es pot restaurar després.',
|
||||
rejectFailed: "No s'ha pogut rebutjar el registre.",
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analítiques',
|
||||
subtitle: "Sessions, logins per app i activitat d'usuaris.",
|
||||
last7: 'Últims 7 dies',
|
||||
last30: 'Últims 30 dies',
|
||||
last90: 'Últims 90 dies',
|
||||
totalUsers: 'Usuaris totals',
|
||||
activeSessions: 'Sessions actives',
|
||||
activeSessionsHint: 'tokens OAuth vigents',
|
||||
loginsToday: 'Logins avui',
|
||||
logins7d: 'Logins últims 7 dies',
|
||||
loginsByDay: 'Logins per dia',
|
||||
appsOAuth: 'Apps (OAuth)',
|
||||
panelAdmin: 'Tauler admin',
|
||||
loginsByApp: 'Logins per app',
|
||||
topUsers: 'Usuaris més actius',
|
||||
noAppLogins: 'Encara no hi ha logins d\'apps en aquest període.',
|
||||
noUserActivity: 'Encara no hi ha activitat en aquest període.',
|
||||
recentActivity: 'Activitat recent',
|
||||
hide: 'Amagar',
|
||||
viewAll: 'Veure-ho tot',
|
||||
colUser: 'Usuari',
|
||||
colApp: 'App',
|
||||
colType: 'Tipus',
|
||||
colIp: 'IP',
|
||||
colWhen: 'Quan',
|
||||
serviceNoUser: '(servei, sense usuari)',
|
||||
deletedApp: '(app esborrada)',
|
||||
recentCount: '{count} esdeveniments recents — clica "Veure-ho tot" per a la taula completa.',
|
||||
chartAriaLabel: 'Logins per dia, apilat per tipus',
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Tauler admin:',
|
||||
barAriaLabel: "{date}: {oauth} logins d'apps, {session} al tauler admin",
|
||||
},
|
||||
};
|
||||
|
||||
export default ca;
|
||||
209
resources/js/i18n/en.ts
Normal file
209
resources/js/i18n/en.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { MessageSchema } from './es';
|
||||
|
||||
const en: MessageSchema = {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
clients: 'OAuth Clients',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Authorized Apps',
|
||||
users: 'Users',
|
||||
analytics: 'Analytics',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Sign out',
|
||||
},
|
||||
common: {
|
||||
loading: 'Loading…',
|
||||
creating: 'Creating…',
|
||||
create: 'Create',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
none: '(none)',
|
||||
},
|
||||
login: {
|
||||
title: 'Sign in',
|
||||
subtitle: 'OAuth2 server — admin panel.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Sign in',
|
||||
submitting: 'Signing in…',
|
||||
noAccount: "Don't have an account?",
|
||||
signUp: 'Sign up',
|
||||
invalidCredentials: 'Invalid credentials.',
|
||||
},
|
||||
register: {
|
||||
title: 'Create account',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
passwordConfirm: 'Confirm password',
|
||||
submit: 'Create account',
|
||||
submitFirstUser: 'Create admin account',
|
||||
submitting: 'Creating…',
|
||||
haveAccount: 'Already have an account?',
|
||||
signIn: 'Sign in',
|
||||
firstUserBanner: "There are no users yet. This account will be the administrator, with immediate access (no approval needed).",
|
||||
pendingTitle: 'Account created',
|
||||
pendingBody: 'An administrator has to approve your account before you can sign in. You may be notified outside the app once it is ready.',
|
||||
backToLogin: 'Back to sign in',
|
||||
},
|
||||
dashboard: {
|
||||
greeting: 'Hi, {name}',
|
||||
subtitle: "This is the OAuth2 server's admin panel.",
|
||||
clientsTitle: 'OAuth Clients',
|
||||
clientsDesc: 'Register apps (Authorization Code+PKCE, Client Credentials or Password Grant).',
|
||||
tokensTitle: 'Personal Access Tokens',
|
||||
tokensDesc: 'Generate tokens for direct API access, like API keys.',
|
||||
appsTitle: 'Authorized Apps',
|
||||
appsDesc: "Third-party apps you've granted access to your account.",
|
||||
usersTitle: 'Users',
|
||||
usersDesc: 'Create, edit role/status and delete accounts (admins only).',
|
||||
analyticsTitle: 'Analytics',
|
||||
analyticsDesc: 'Active sessions, logins by app and most active users (admins only).',
|
||||
apiTitle: 'REST API',
|
||||
apiDesc: 'This server exposes the {resource} resource (scopes {read}/{write}) so third-party apps can manage users via OAuth2. Requires a Passport access token. Interactive docs at {link}.',
|
||||
},
|
||||
clients: {
|
||||
title: 'OAuth Clients',
|
||||
subtitle: 'Registered apps that can request tokens from this server.',
|
||||
secretWarning: "Save this client secret now, you won't be able to see it again:",
|
||||
clientIdLabel: 'client_id:',
|
||||
clientSecretLabel: 'client_secret:',
|
||||
newClient: 'New client',
|
||||
name: 'Name',
|
||||
grantType: 'Grant type',
|
||||
grantAuthCode: 'Authorization Code + PKCE',
|
||||
grantClientCredentials: 'Client Credentials',
|
||||
grantPassword: 'Password Grant',
|
||||
redirectUris: 'Redirect URIs (comma-separated)',
|
||||
confidentialLabel: 'Confidential client (with secret). Uncheck for a public client that only uses PKCE.',
|
||||
create: 'Create client',
|
||||
empty: "You haven't created any clients yet.",
|
||||
revoke: 'Revoke',
|
||||
revokeConfirm: 'Revoke client "{name}"? Its tokens will stop working.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
subtitle: 'Direct API access tokens, without going through the authorize flow (like an API key).',
|
||||
tokenWarning: "Save this token now, you won't be able to see it again:",
|
||||
newToken: 'New token',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'My script',
|
||||
scopes: 'Scopes',
|
||||
create: 'Create token',
|
||||
empty: "You haven't created any tokens yet.",
|
||||
revoke: 'Revoke',
|
||||
revokeConfirm: 'Revoke token "{name}"?',
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorizedApps: {
|
||||
title: 'Authorized Apps',
|
||||
subtitle: "Third-party apps you've granted access to your account.",
|
||||
empty: "You haven't authorized any app yet.",
|
||||
revoke: 'Revoke access',
|
||||
revokeConfirm: 'Revoke access for "{name}"?',
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorize: {
|
||||
title: 'Authorize application',
|
||||
wantsAccess: '{client} wants to access your account ({email}) with the following permissions:',
|
||||
noScopes: 'No additional permissions requested (basic access).',
|
||||
approve: 'Authorize',
|
||||
cancel: 'Cancel',
|
||||
notFoundTitle: 'Request not found',
|
||||
notFoundBody: 'This screen only works when you arrive here redirected by a client application via {path}. Try again from that app.',
|
||||
goDashboard: 'Go to dashboard',
|
||||
},
|
||||
adminUsers: {
|
||||
title: 'Users',
|
||||
subtitle: 'Create, edit role/status and delete accounts.',
|
||||
newUser: 'New user',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
role: 'Role',
|
||||
roleUser: 'User',
|
||||
roleAdmin: 'Admin',
|
||||
create: 'Create user',
|
||||
colName: 'Name',
|
||||
colEmail: 'Email',
|
||||
colRole: 'Role',
|
||||
colStatus: 'Status',
|
||||
active: 'Active',
|
||||
inactive: 'Deactivated',
|
||||
noRole: '(no role)',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: "Delete user \"{name}\"? They'll go to the trash and can be restored.",
|
||||
deleteFailed: 'Could not delete the user.',
|
||||
newPassword: 'New password',
|
||||
noChange: '(no change)',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
appRolesTitle: 'Roles per app',
|
||||
appRolesDesc: 'The global role above rules this panel. Here you set what role {name} has inside each app when that app asks for their user via API.',
|
||||
noAppRoles: 'No roles assigned in any app yet.',
|
||||
chooseApp: 'Choose app…',
|
||||
assign: 'Assign',
|
||||
assignFailed: 'Could not assign the role.',
|
||||
trashTitle: 'Trash',
|
||||
viewTrash: 'View trash',
|
||||
viewActive: 'View active',
|
||||
trashEmpty: 'No deleted users.',
|
||||
deletedAt: 'Deleted on {date}',
|
||||
restore: 'Restore',
|
||||
restoreFailed: 'Could not restore the user.',
|
||||
forceDelete: 'Delete permanently',
|
||||
forceDeleteConfirm: 'Permanently delete "{name}"? This action cannot be undone.',
|
||||
forceDeleteFailed: 'Could not permanently delete the user.',
|
||||
pendingTitle: 'Pending',
|
||||
viewPending: 'Pending',
|
||||
pendingEmpty: 'No registrations pending approval.',
|
||||
registeredAt: 'Registered on {date}',
|
||||
approve: 'Approve',
|
||||
approveFailed: 'Could not approve the user.',
|
||||
reject: 'Reject',
|
||||
rejectConfirm: 'Reject the registration for "{name}"? It will go to the trash and can be restored later.',
|
||||
rejectFailed: 'Could not reject the registration.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analytics',
|
||||
subtitle: 'Sessions, logins by app and user activity.',
|
||||
last7: 'Last 7 days',
|
||||
last30: 'Last 30 days',
|
||||
last90: 'Last 90 days',
|
||||
totalUsers: 'Total users',
|
||||
activeSessions: 'Active sessions',
|
||||
activeSessionsHint: 'valid OAuth tokens',
|
||||
loginsToday: 'Logins today',
|
||||
logins7d: 'Logins last 7 days',
|
||||
loginsByDay: 'Logins by day',
|
||||
appsOAuth: 'Apps (OAuth)',
|
||||
panelAdmin: 'Admin panel',
|
||||
loginsByApp: 'Logins by app',
|
||||
topUsers: 'Most active users',
|
||||
noAppLogins: 'No app logins in this period yet.',
|
||||
noUserActivity: 'No activity in this period yet.',
|
||||
recentActivity: 'Recent activity',
|
||||
hide: 'Hide',
|
||||
viewAll: 'View all',
|
||||
colUser: 'User',
|
||||
colApp: 'App',
|
||||
colType: 'Type',
|
||||
colIp: 'IP',
|
||||
colWhen: 'When',
|
||||
serviceNoUser: '(service, no user)',
|
||||
deletedApp: '(deleted app)',
|
||||
recentCount: '{count} recent events — click "View all" for the full table.',
|
||||
chartAriaLabel: 'Logins by day, stacked by type',
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Admin panel:',
|
||||
barAriaLabel: '{date}: {oauth} app logins, {session} to admin panel',
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
208
resources/js/i18n/es.ts
Normal file
208
resources/js/i18n/es.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
const es = {
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
clients: 'Clientes OAuth',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Apps autorizadas',
|
||||
users: 'Usuarios',
|
||||
analytics: 'Analíticas',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Salir',
|
||||
},
|
||||
common: {
|
||||
loading: 'Cargando…',
|
||||
creating: 'Creando…',
|
||||
create: 'Crear',
|
||||
save: 'Guardar',
|
||||
cancel: 'Cancelar',
|
||||
close: 'Cerrar',
|
||||
edit: 'Editar',
|
||||
delete: 'Borrar',
|
||||
none: '(ninguno)',
|
||||
},
|
||||
login: {
|
||||
title: 'Ingresar',
|
||||
subtitle: 'Servidor OAuth2 — panel de administración.',
|
||||
email: 'Email',
|
||||
password: 'Contraseña',
|
||||
submit: 'Ingresar',
|
||||
submitting: 'Ingresando…',
|
||||
noAccount: '¿No tenés cuenta?',
|
||||
signUp: 'Registrate',
|
||||
invalidCredentials: 'Credenciales inválidas.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear cuenta',
|
||||
name: 'Nombre',
|
||||
email: 'Email',
|
||||
password: 'Contraseña',
|
||||
passwordConfirm: 'Confirmar contraseña',
|
||||
submit: 'Crear cuenta',
|
||||
submitFirstUser: 'Crear cuenta de administrador',
|
||||
submitting: 'Creando…',
|
||||
haveAccount: '¿Ya tenés cuenta?',
|
||||
signIn: 'Ingresá',
|
||||
firstUserBanner: 'Todavía no hay ningún usuario. Esta cuenta va a quedar como administradora, con acceso inmediato (sin aprobación).',
|
||||
pendingTitle: 'Cuenta creada',
|
||||
pendingBody: 'Un administrador tiene que aprobar tu cuenta antes de que puedas ingresar. Te va a poder avisar por fuera de la app cuando esté lista.',
|
||||
backToLogin: 'Volver a ingresar',
|
||||
},
|
||||
dashboard: {
|
||||
greeting: 'Hola, {name}',
|
||||
subtitle: 'Este es el panel de administración del servidor OAuth2.',
|
||||
clientsTitle: 'Clientes OAuth',
|
||||
clientsDesc: 'Registrá apps (Authorization Code+PKCE, Client Credentials o Password Grant).',
|
||||
tokensTitle: 'Personal Access Tokens',
|
||||
tokensDesc: 'Generá tokens de acceso directo a la API, como API keys.',
|
||||
appsTitle: 'Apps autorizadas',
|
||||
appsDesc: 'Apps de terceros a las que le diste acceso a tu cuenta.',
|
||||
usersTitle: 'Usuarios',
|
||||
usersDesc: 'Alta, edición de rol/estado y baja de cuentas (solo admins).',
|
||||
analyticsTitle: 'Analíticas',
|
||||
analyticsDesc: 'Sesiones activas, logins por app y usuarios más activos (solo admins).',
|
||||
apiTitle: 'API REST',
|
||||
apiDesc: 'Este servidor expone el recurso {resource} (scopes {read}/{write}) para que aplicaciones de terceros gestionen usuarios vía OAuth2. Requiere un access token de Passport. Documentación interactiva en {link}.',
|
||||
},
|
||||
clients: {
|
||||
title: 'Clientes OAuth',
|
||||
subtitle: 'Apps registradas que pueden pedir tokens a este servidor.',
|
||||
secretWarning: 'Guardá este client secret ahora, no se puede volver a ver:',
|
||||
clientIdLabel: 'client_id:',
|
||||
clientSecretLabel: 'client_secret:',
|
||||
newClient: 'Nuevo cliente',
|
||||
name: 'Nombre',
|
||||
grantType: 'Grant type',
|
||||
grantAuthCode: 'Authorization Code + PKCE',
|
||||
grantClientCredentials: 'Client Credentials',
|
||||
grantPassword: 'Password Grant',
|
||||
redirectUris: 'Redirect URIs (separadas por coma)',
|
||||
confidentialLabel: 'Cliente confidencial (con secret). Desmarcá para un cliente público que solo use PKCE.',
|
||||
create: 'Crear cliente',
|
||||
empty: 'Todavía no creaste ningún cliente.',
|
||||
revoke: 'Revocar',
|
||||
revokeConfirm: '¿Revocar el cliente "{name}"? Sus tokens dejarán de funcionar.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
subtitle: 'Tokens de acceso directo a la API, sin pasar por el flujo de authorize (tipo API key).',
|
||||
tokenWarning: 'Guardá este token ahora, no se puede volver a ver:',
|
||||
newToken: 'Nuevo token',
|
||||
name: 'Nombre',
|
||||
namePlaceholder: 'Mi script',
|
||||
scopes: 'Scopes',
|
||||
create: 'Crear token',
|
||||
empty: 'Todavía no creaste ningún token.',
|
||||
revoke: 'Revocar',
|
||||
revokeConfirm: '¿Revocar el token "{name}"?',
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorizedApps: {
|
||||
title: 'Apps autorizadas',
|
||||
subtitle: 'Aplicaciones de terceros a las que le diste acceso a tu cuenta.',
|
||||
empty: 'No autorizaste ninguna app todavía.',
|
||||
revoke: 'Revocar acceso',
|
||||
revokeConfirm: '¿Revocar el acceso de "{name}"?',
|
||||
scopesLabel: 'scopes',
|
||||
},
|
||||
authorize: {
|
||||
title: 'Autorizar aplicación',
|
||||
wantsAccess: '{client} quiere acceder a tu cuenta ({email}) con los siguientes permisos:',
|
||||
noScopes: 'No solicita permisos adicionales (acceso básico).',
|
||||
approve: 'Autorizar',
|
||||
cancel: 'Cancelar',
|
||||
notFoundTitle: 'Solicitud no encontrada',
|
||||
notFoundBody: 'Esta pantalla solo funciona cuando llegás acá redirigido por una aplicación cliente vía {path}. Volvé a intentar desde esa app.',
|
||||
goDashboard: 'Ir al dashboard',
|
||||
},
|
||||
adminUsers: {
|
||||
title: 'Usuarios',
|
||||
subtitle: 'Alta, edición de rol/estado y baja de cuentas.',
|
||||
newUser: 'Nuevo usuario',
|
||||
name: 'Nombre',
|
||||
email: 'Email',
|
||||
password: 'Contraseña',
|
||||
role: 'Rol',
|
||||
roleUser: 'Usuario',
|
||||
roleAdmin: 'Admin',
|
||||
create: 'Crear usuario',
|
||||
colName: 'Nombre',
|
||||
colEmail: 'Email',
|
||||
colRole: 'Rol',
|
||||
colStatus: 'Estado',
|
||||
active: 'Activo',
|
||||
inactive: 'Desactivado',
|
||||
noRole: '(sin rol)',
|
||||
edit: 'Editar',
|
||||
delete: 'Borrar',
|
||||
deleteConfirm: '¿Borrar al usuario "{name}"? Va a la papelera y se puede restaurar.',
|
||||
deleteFailed: 'No se pudo borrar el usuario.',
|
||||
newPassword: 'Nueva contraseña',
|
||||
noChange: '(sin cambios)',
|
||||
save: 'Guardar',
|
||||
cancel: 'Cancelar',
|
||||
appRolesTitle: 'Roles por app',
|
||||
appRolesDesc: 'El rol global de arriba manda en este panel. Acá se define qué rol tiene {name} dentro de cada app cuando esa app pide su usuario por API.',
|
||||
noAppRoles: 'Sin roles asignados en ninguna app todavía.',
|
||||
chooseApp: 'Elegir app…',
|
||||
assign: 'Asignar',
|
||||
assignFailed: 'No se pudo asignar el rol.',
|
||||
trashTitle: 'Papelera',
|
||||
viewTrash: 'Ver papelera',
|
||||
viewActive: 'Ver activos',
|
||||
trashEmpty: 'No hay usuarios borrados.',
|
||||
deletedAt: 'Borrado el {date}',
|
||||
restore: 'Restaurar',
|
||||
restoreFailed: 'No se pudo restaurar el usuario.',
|
||||
forceDelete: 'Borrar definitivamente',
|
||||
forceDeleteConfirm: '¿Borrar definitivamente a "{name}"? Esta acción no se puede deshacer.',
|
||||
forceDeleteFailed: 'No se pudo borrar definitivamente.',
|
||||
pendingTitle: 'Pendientes',
|
||||
viewPending: 'Pendientes',
|
||||
pendingEmpty: 'No hay registros pendientes de aprobación.',
|
||||
registeredAt: 'Se registró el {date}',
|
||||
approve: 'Aprobar',
|
||||
approveFailed: 'No se pudo aprobar el usuario.',
|
||||
reject: 'Rechazar',
|
||||
rejectConfirm: '¿Rechazar el registro de "{name}"? Va a la papelera y se puede restaurar después.',
|
||||
rejectFailed: 'No se pudo rechazar el registro.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analíticas',
|
||||
subtitle: 'Sesiones, logins por app y actividad de usuarios.',
|
||||
last7: 'Últimos 7 días',
|
||||
last30: 'Últimos 30 días',
|
||||
last90: 'Últimos 90 días',
|
||||
totalUsers: 'Usuarios totales',
|
||||
activeSessions: 'Sesiones activas',
|
||||
activeSessionsHint: 'tokens OAuth vigentes',
|
||||
loginsToday: 'Logins hoy',
|
||||
logins7d: 'Logins últimos 7 días',
|
||||
loginsByDay: 'Logins por día',
|
||||
appsOAuth: 'Apps (OAuth)',
|
||||
panelAdmin: 'Panel admin',
|
||||
loginsByApp: 'Logins por app',
|
||||
topUsers: 'Usuarios más activos',
|
||||
noAppLogins: 'Todavía no hay logins de apps en este período.',
|
||||
noUserActivity: 'Todavía no hay actividad en este período.',
|
||||
recentActivity: 'Actividad reciente',
|
||||
hide: 'Ocultar',
|
||||
viewAll: 'Ver todo',
|
||||
colUser: 'Usuario',
|
||||
colApp: 'App',
|
||||
colType: 'Tipo',
|
||||
colIp: 'IP',
|
||||
colWhen: 'Cuándo',
|
||||
serviceNoUser: '(servicio, sin usuario)',
|
||||
deletedApp: '(app borrada)',
|
||||
recentCount: '{count} eventos recientes — click en "Ver todo" para la tabla completa.',
|
||||
chartAriaLabel: 'Logins por día, apilado por tipo',
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Panel admin:',
|
||||
barAriaLabel: '{date}: {oauth} logins de apps, {session} al panel admin',
|
||||
},
|
||||
};
|
||||
|
||||
export default es;
|
||||
export type MessageSchema = typeof es;
|
||||
20
resources/js/i18n/index.ts
Normal file
20
resources/js/i18n/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import ca from './ca';
|
||||
import en from './en';
|
||||
import es, { type MessageSchema } from './es';
|
||||
|
||||
const SUPPORTED = ['es', 'en', 'ca'] as const;
|
||||
type Locale = (typeof SUPPORTED)[number];
|
||||
|
||||
function isSupported(locale: string): locale is Locale {
|
||||
return (SUPPORTED as readonly string[]).includes(locale);
|
||||
}
|
||||
|
||||
const i18n = createI18n<[MessageSchema], Locale>({
|
||||
legacy: false,
|
||||
locale: isSupported(window.__LOCALE__) ? window.__LOCALE__ : 'es',
|
||||
fallbackLocale: 'es',
|
||||
messages: { en, es, ca },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
70
resources/js/pages/Authorize.vue
Normal file
70
resources/js/pages/Authorize.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const data = window.__OAUTH_AUTHORIZE__ ?? null;
|
||||
const approveForm = ref<HTMLFormElement | null>(null);
|
||||
const denyForm = ref<HTMLFormElement | null>(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card auth-card--wide">
|
||||
<template v-if="data">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('authorize.title') }}</h1>
|
||||
<p class="auth-card__subtitle">
|
||||
<i18n-t keypath="authorize.wantsAccess" tag="span">
|
||||
<template #client><strong>{{ data.client.name }}</strong></template>
|
||||
<template #email>{{ data.user.email }}</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
|
||||
<ul class="auth-card__scopes">
|
||||
<li v-for="scope in data.scopes" :key="scope.id" class="auth-card__scope">
|
||||
<span class="auth-card__scope-check">✓</span>
|
||||
{{ scope.description }}
|
||||
</li>
|
||||
<li v-if="!data.scopes.length" class="auth-card__scope">
|
||||
{{ t('authorize.noScopes') }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="auth-card__actions">
|
||||
<form ref="approveForm" method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="_token" :value="data.csrfToken" />
|
||||
<input type="hidden" name="auth_token" :value="data.authToken" />
|
||||
<button type="submit" class="btn btn--primary btn--pill btn--full">
|
||||
{{ t('authorize.approve') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form ref="denyForm" method="POST" action="/oauth/authorize">
|
||||
<input type="hidden" name="_token" :value="data.csrfToken" />
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<input type="hidden" name="auth_token" :value="data.authToken" />
|
||||
<button type="submit" class="btn btn--secondary btn--pill btn--full">
|
||||
{{ t('authorize.cancel') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="auth-card__title">{{ t('authorize.notFoundTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">
|
||||
<i18n-t keypath="authorize.notFoundBody" tag="span">
|
||||
<template #path><code>/oauth/authorize</code></template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<p class="auth-card__footer">
|
||||
<router-link to="/" class="link">{{ t('authorize.goDashboard') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
67
resources/js/pages/AuthorizedApps.vue
Normal file
67
resources/js/pages/AuthorizedApps.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { AuthorizedAppToken } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tokens = ref<AuthorizedAppToken[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<AuthorizedAppToken[]>('/web-api/authorized-apps');
|
||||
tokens.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function revoke(token: AuthorizedAppToken): Promise<void> {
|
||||
if (!confirm(t('authorizedApps.revokeConfirm', { name: token.client.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/authorized-apps/${token.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('authorizedApps.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('authorizedApps.subtitle') }}</p>
|
||||
|
||||
<div class="authorized-apps__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!tokens.length" class="page-subtitle">{{ t('authorizedApps.empty') }}</p>
|
||||
|
||||
<div v-for="token in tokens" :key="token.id" class="card authorized-apps__item">
|
||||
<div>
|
||||
<p class="card__title">{{ token.client.name }}</p>
|
||||
<p class="authorized-apps__item-meta">{{ t('authorizedApps.scopesLabel') }}: {{ token.scopes?.join(', ') || t('common.none') }}</p>
|
||||
</div>
|
||||
<button @click="revoke(token)" class="link link--danger">{{ t('authorizedApps.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.authorized-apps__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.authorized-apps__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.authorized-apps__item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
184
resources/js/pages/Clients.vue
Normal file
184
resources/js/pages/Clients.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { OAuthClient } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const clients = ref<OAuthClient[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const newSecret = ref<{ name: string; secret: string; id: string } | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uris: '',
|
||||
confidential: true,
|
||||
});
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<OAuthClient[]>('/web-api/clients');
|
||||
clients.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function createClient(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
newSecret.value = null;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
grant_type: form.grant_type,
|
||||
confidential: form.grant_type === 'client_credentials' ? true : form.confidential,
|
||||
redirect_uris: form.redirect_uris
|
||||
? form.redirect_uris.split(',').map((u) => u.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
|
||||
const { data } = await axios.post<{ client: OAuthClient; plain_secret: string | null }>('/web-api/clients', payload);
|
||||
|
||||
if (data.plain_secret) {
|
||||
newSecret.value = { name: data.client.name, secret: data.plain_secret, id: data.client.id };
|
||||
}
|
||||
|
||||
form.name = '';
|
||||
form.redirect_uris = '';
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(client: OAuthClient): Promise<void> {
|
||||
if (!confirm(t('clients.revokeConfirm', { name: client.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/clients/${client.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
function grantLabel(grantTypes: string[]): string {
|
||||
if (grantTypes.includes('authorization_code')) return t('clients.grantAuthCode');
|
||||
if (grantTypes.includes('client_credentials')) return t('clients.grantClientCredentials');
|
||||
if (grantTypes.includes('password')) return t('clients.grantPassword');
|
||||
return grantTypes.join(', ');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('clients.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('clients.subtitle') }}</p>
|
||||
|
||||
<div v-if="newSecret" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('clients.secretWarning') }}</p>
|
||||
<p class="banner__line"><strong>{{ t('clients.clientIdLabel') }}</strong> <code class="banner__code">{{ newSecret.id }}</code></p>
|
||||
<p class="banner__line"><strong>{{ t('clients.clientSecretLabel') }}</strong> <code class="banner__code">{{ newSecret.secret }}</code></p>
|
||||
<button class="banner__close" @click="newSecret = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<form class="card clients__form" @submit.prevent="createClient">
|
||||
<h2 class="card__title clients__form-title">{{ t('clients.newClient') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('clients.name') }}</label>
|
||||
<input v-model="form.name" required class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('clients.grantType') }}</label>
|
||||
<select v-model="form.grant_type" class="field__select">
|
||||
<option value="authorization_code">{{ t('clients.grantAuthCode') }}</option>
|
||||
<option value="client_credentials">{{ t('clients.grantClientCredentials') }}</option>
|
||||
<option value="password">{{ t('clients.grantPassword') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.grant_type === 'authorization_code'" class="field clients__form-wide">
|
||||
<label class="field__label">{{ t('clients.redirectUris') }}</label>
|
||||
<input v-model="form.redirect_uris" placeholder="https://mi-app.com/callback" class="field__input" />
|
||||
<p v-if="errors.redirect_uris" class="field__error">{{ errors.redirect_uris[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.grant_type === 'authorization_code'" class="field__checkbox-row">
|
||||
<input id="confidential" type="checkbox" v-model="form.confidential" class="field__checkbox" />
|
||||
<label for="confidential">{{ t('clients.confidentialLabel') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="clients__form-wide">
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('clients.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="clients__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!clients.length" class="page-subtitle">{{ t('clients.empty') }}</p>
|
||||
|
||||
<div v-for="client in clients" :key="client.id" class="card clients__item">
|
||||
<div>
|
||||
<p class="card__title">{{ client.name }}</p>
|
||||
<p class="card__subtitle">{{ grantLabel(client.grant_types) }}</p>
|
||||
<p class="clients__item-meta">{{ t('clients.clientIdShort') }}: {{ client.id }}</p>
|
||||
<p v-if="client.redirect_uris?.length" class="clients__item-meta">
|
||||
{{ t('clients.redirectLabel') }}: {{ client.redirect_uris.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clients__form {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.clients__form-title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.clients__form-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.clients__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clients__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clients__item-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.clients__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
resources/js/pages/Dashboard.vue
Normal file
72
resources/js/pages/Dashboard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('dashboard.greeting', { name: auth.user?.name }) }}</h1>
|
||||
<p class="page-subtitle">{{ t('dashboard.subtitle') }}</p>
|
||||
|
||||
<div class="dashboard__grid">
|
||||
<router-link to="/clients" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.clientsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.clientsDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/tokens" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.tokensTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.tokensDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/authorized-apps" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.appsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.appsDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="auth.isAdmin" to="/admin/users" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.usersTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.usersDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link v-if="auth.isAdmin" to="/admin/analytics" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.analyticsTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.analyticsDesc') }}</p>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard__api-card">
|
||||
<h2 class="card__title">{{ t('dashboard.apiTitle') }}</h2>
|
||||
<p class="card__subtitle">
|
||||
<i18n-t keypath="dashboard.apiDesc" tag="span">
|
||||
<template #resource><code>/api/users</code></template>
|
||||
<template #read><code>users:read</code></template>
|
||||
<template #write><code>users:write</code></template>
|
||||
<template #link><a href="/api/documentation" class="link">/api/documentation</a></template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard__grid {
|
||||
margin-top: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard__api-card {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dashboard__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
resources/js/pages/Login.vue
Normal file
69
resources/js/pages/Login.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ email: '', password: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const intended = await auth.login(form);
|
||||
|
||||
if (intended) {
|
||||
window.location.href = intended;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push((route.query.redirect as string) || { name: 'dashboard' });
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { email: [t('login.invalidCredentials')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('login.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('login.subtitle') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="field__input" />
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.password') }}</label>
|
||||
<input v-model="form.password" type="password" required class="field__input" />
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('login.submitting') : t('login.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
{{ t('login.noAccount') }}
|
||||
<router-link to="/register" class="link">{{ t('login.signUp') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
103
resources/js/pages/Register.vue
Normal file
103
resources/js/pages/Register.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ name: '', email: '', password: '', password_confirmation: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
const registered = ref(false);
|
||||
const isFirstUser = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isFirstUser.value = await auth.setupIsEmpty();
|
||||
} catch {
|
||||
isFirstUser.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const result = await auth.register(form);
|
||||
|
||||
if (result.bootstrap) {
|
||||
router.push({ name: 'dashboard' });
|
||||
return;
|
||||
}
|
||||
|
||||
registered.value = true;
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-page__glow"></div>
|
||||
|
||||
<div class="auth-card">
|
||||
<template v-if="registered">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('register.pendingTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('register.pendingBody') }}</p>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<router-link to="/login" class="link">{{ t('register.backToLogin') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<h1 class="auth-card__title">{{ t('register.title') }}</h1>
|
||||
|
||||
<p v-if="isFirstUser" class="auth-card__banner">{{ t('register.firstUserBanner') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('register.name') }}</label>
|
||||
<input v-model="form.name" type="text" required class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('register.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="field__input" />
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('register.password') }}</label>
|
||||
<input v-model="form.password" type="password" required class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('register.passwordConfirm') }}</label>
|
||||
<input v-model="form.password_confirmation" type="password" required class="field__input" />
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('register.submitting') : (isFirstUser ? t('register.submitFirstUser') : t('register.submit')) }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
{{ t('register.haveAccount') }}
|
||||
<router-link to="/login" class="link">{{ t('register.signIn') }}</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
145
resources/js/pages/Tokens.vue
Normal file
145
resources/js/pages/Tokens.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PersonalAccessToken, Scope } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tokens = ref<PersonalAccessToken[]>([]);
|
||||
const scopes = ref<Scope[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const newToken = ref<string | null>(null);
|
||||
|
||||
const form = reactive<{ name: string; scopes: string[] }>({ name: '', scopes: [] });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const [tokensRes, scopesRes] = await Promise.all([
|
||||
axios.get<PersonalAccessToken[]>('/web-api/personal-access-tokens'),
|
||||
axios.get<Scope[]>('/web-api/scopes'),
|
||||
]);
|
||||
tokens.value = tokensRes.data;
|
||||
scopes.value = scopesRes.data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
newToken.value = null;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ access_token: string }>('/web-api/personal-access-tokens', form);
|
||||
newToken.value = data.access_token;
|
||||
form.name = '';
|
||||
form.scopes = [];
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token: PersonalAccessToken): Promise<void> {
|
||||
if (!confirm(t('tokens.revokeConfirm', { name: token.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/personal-access-tokens/${token.id}`);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('tokens.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('tokens.subtitle') }}</p>
|
||||
|
||||
<div v-if="newToken" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('tokens.tokenWarning') }}</p>
|
||||
<p class="banner__line tokens__token"><code class="banner__code">{{ newToken }}</code></p>
|
||||
<button class="banner__close" @click="newToken = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<form class="card tokens__form" @submit.prevent="create">
|
||||
<h2 class="card__title">{{ t('tokens.newToken') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('tokens.name') }}</label>
|
||||
<input v-model="form.name" required :placeholder="t('tokens.namePlaceholder')" class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('tokens.scopes') }}</label>
|
||||
<div class="tokens__scopes">
|
||||
<label v-for="scope in scopes" :key="scope.id" class="field__checkbox-row">
|
||||
<input type="checkbox" :value="scope.id" v-model="form.scopes" class="field__checkbox" />
|
||||
{{ scope.id }} — {{ scope.description }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('tokens.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="tokens__list">
|
||||
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!tokens.length" class="page-subtitle">{{ t('tokens.empty') }}</p>
|
||||
|
||||
<div v-for="token in tokens" :key="token.id" class="card tokens__item">
|
||||
<div>
|
||||
<p class="card__title">{{ token.name }}</p>
|
||||
<p class="tokens__item-meta">{{ t('tokens.scopesLabel') }}: {{ token.scopes?.join(', ') || t('common.none') }}</p>
|
||||
</div>
|
||||
<button @click="revoke(token)" class="link link--danger">{{ t('tokens.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tokens__form {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tokens__scopes {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tokens__token {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tokens__list {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tokens__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tokens__item-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
179
resources/js/pages/admin/Analytics.vue
Normal file
179
resources/js/pages/admin/Analytics.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatTile from '../../components/charts/StatTile.vue';
|
||||
import BarList from '../../components/charts/BarList.vue';
|
||||
import DailyLoginsChart from '../../components/charts/DailyLoginsChart.vue';
|
||||
import type { AnalyticsSummary } from '../../types/models';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const loading = ref(true);
|
||||
const days = ref(30);
|
||||
const summary = ref<AnalyticsSummary | null>(null);
|
||||
const showTable = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<AnalyticsSummary>('/web-api/admin/analytics/summary', { params: { days: days.value } });
|
||||
summary.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch(days, load);
|
||||
onMounted(load);
|
||||
|
||||
const appItems = computed(() => (summary.value?.logins_by_app ?? []).map((a) => ({ label: a.client_name, value: a.count })));
|
||||
const userItems = computed(() => (summary.value?.top_users ?? []).map((u) => ({ label: u.name, value: u.count })));
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="analytics">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('analytics.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('analytics.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
|
||||
<select v-model.number="days" class="pill-select">
|
||||
<option :value="7">{{ t('analytics.last7') }}</option>
|
||||
<option :value="30">{{ t('analytics.last30') }}</option>
|
||||
<option :value="90">{{ t('analytics.last90') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="loading && !summary" class="analytics__loading">{{ t('common.loading') }}</p>
|
||||
|
||||
<template v-else-if="summary">
|
||||
<div class="analytics__kpis" :class="{ 'analytics__section--loading': loading }">
|
||||
<StatTile :label="t('analytics.totalUsers')" :value="summary.kpis.total_users" />
|
||||
<StatTile :label="t('analytics.activeSessions')" :value="summary.kpis.active_sessions" :hint="t('analytics.activeSessionsHint')" />
|
||||
<StatTile :label="t('analytics.loginsToday')" :value="summary.kpis.logins_today" />
|
||||
<StatTile :label="t('analytics.logins7d')" :value="summary.kpis.logins_7d" />
|
||||
</div>
|
||||
|
||||
<div class="analytics__chart" :class="{ 'analytics__section--loading': loading }">
|
||||
<DailyLoginsChart :data="summary.logins_by_day" />
|
||||
</div>
|
||||
|
||||
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
|
||||
<BarList :title="t('analytics.loginsByApp')" :items="appItems" :empty-text="t('analytics.noAppLogins')" />
|
||||
<BarList :title="t('analytics.topUsers')" :items="userItems" :empty-text="t('analytics.noUserActivity')" />
|
||||
</div>
|
||||
|
||||
<div class="card analytics__recent" :class="{ 'analytics__section--loading': loading }">
|
||||
<div class="analytics__recent-header">
|
||||
<h3 class="card__title">{{ t('analytics.recentActivity') }}</h3>
|
||||
<button @click="showTable = !showTable" class="link analytics__toggle">
|
||||
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showTable" class="analytics__table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('analytics.colUser') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colApp') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colType') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colIp') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colWhen') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="(event, i) in summary.recent" :key="i">
|
||||
<td class="table__cell table__cell--primary">{{ event.user_name ?? t('analytics.serviceNoUser') }}</td>
|
||||
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : (event.client_name ?? t('analytics.deletedApp')) }}</td>
|
||||
<td class="table__cell">{{ event.type === 'session' ? t('analytics.panelAdmin') : 'OAuth' }}</td>
|
||||
<td class="table__cell">{{ event.ip_address }}</td>
|
||||
<td class="table__cell analytics__when">{{ formatDateTime(event.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="analytics__recent-count">{{ t('analytics.recentCount', { count: summary.recent.length }) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.analytics {
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
}
|
||||
|
||||
.analytics__loading {
|
||||
margin-top: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.analytics__kpis {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics__chart {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.analytics__bar-lists {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics__recent {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.analytics__recent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.analytics__toggle {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics__table-wrap {
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.analytics__when {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.analytics__recent-count {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.analytics__section--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.analytics__kpis {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analytics__bar-lists {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
567
resources/js/pages/admin/Users.vue
Normal file
567
resources/js/pages/admin/Users.vue
Normal file
@@ -0,0 +1,567 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import type { AppRoleGroup, OAuthApp, User } from '../../types/models';
|
||||
|
||||
type View = 'active' | 'pending' | 'trashed';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const pendingUsers = ref<User[]>([]);
|
||||
const trashedUsers = ref<User[]>([]);
|
||||
const view = ref<View>('active');
|
||||
const apps = ref<OAuthApp[]>([]);
|
||||
const appRoles = ref<AppRoleGroup[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
const createForm = reactive({ name: '', email: '', password: '', role: 'user' });
|
||||
const editForm = reactive({ name: '', email: '', password: '', role: 'user', is_active: true });
|
||||
const newAppRole = reactive({ client_id: '', role: 'user' });
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (view.value === 'pending') return t('adminUsers.pendingTitle');
|
||||
if (view.value === 'trashed') return t('adminUsers.trashTitle');
|
||||
return t('adminUsers.title');
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const [usersRes, appsRes, pendingRes] = await Promise.all([
|
||||
axios.get<User[]>('/web-api/admin/users'),
|
||||
axios.get<OAuthApp[]>('/web-api/admin/apps'),
|
||||
axios.get<User[]>('/web-api/admin/users/pending'),
|
||||
]);
|
||||
users.value = usersRes.data;
|
||||
apps.value = appsRes.data;
|
||||
pendingUsers.value = pendingRes.data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadPending(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<User[]>('/web-api/admin/users/pending');
|
||||
pendingUsers.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function loadTrashed(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<User[]>('/web-api/admin/users/trashed');
|
||||
trashedUsers.value = data;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function setView(next: View): Promise<void> {
|
||||
view.value = next;
|
||||
editingId.value = null;
|
||||
|
||||
if (next === 'pending') await loadPending();
|
||||
if (next === 'trashed') await loadTrashed();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function approveUser(user: User): Promise<void> {
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/approve`);
|
||||
await loadPending();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.approveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectUser(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.rejectConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}`);
|
||||
await loadPending();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.rejectFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreUser(user: User): Promise<void> {
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/restore`);
|
||||
await loadTrashed();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.restoreFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function forceDeleteUser(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.forceDeleteConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/force`);
|
||||
await loadTrashed();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.forceDeleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppRoles(user: User): Promise<void> {
|
||||
const { data } = await axios.get<AppRoleGroup[]>(`/web-api/admin/users/${user.id}/app-roles`);
|
||||
appRoles.value = data;
|
||||
}
|
||||
|
||||
async function addAppRole(user: User): Promise<void> {
|
||||
if (!newAppRole.client_id) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/web-api/admin/users/${user.id}/app-roles`, newAppRole);
|
||||
newAppRole.client_id = '';
|
||||
newAppRole.role = 'user';
|
||||
await loadAppRoles(user);
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.assignFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAppRole(user: User, clientId: string, role: string): Promise<void> {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/app-roles/${clientId}/${role}`);
|
||||
await loadAppRoles(user);
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
await axios.post('/web-api/admin/users', createForm);
|
||||
createForm.name = '';
|
||||
createForm.email = '';
|
||||
createForm.password = '';
|
||||
createForm.role = 'user';
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(user: User): void {
|
||||
editingId.value = user.id;
|
||||
editForm.name = user.name;
|
||||
editForm.email = user.email;
|
||||
editForm.password = '';
|
||||
editForm.role = user.roles[0] ?? 'user';
|
||||
editForm.is_active = user.is_active;
|
||||
errors.value = {};
|
||||
appRoles.value = [];
|
||||
loadAppRoles(user);
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
editingId.value = null;
|
||||
errors.value = {};
|
||||
}
|
||||
|
||||
async function saveEdit(user: User): Promise<void> {
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = { ...editForm };
|
||||
if (!payload.password) delete payload.password;
|
||||
|
||||
await axios.put(`/web-api/admin/users/${user.id}`, payload);
|
||||
editingId.value = null;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? {};
|
||||
if (e.response?.status === 422 && e.response.data.message) {
|
||||
alert(e.response.data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.deleteConfirm', { name: user.name }))) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`/web-api/admin/users/${user.id}`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('adminUsers.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ pageTitle }}</h1>
|
||||
<p class="page-subtitle">{{ t('adminUsers.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="users__tabs">
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'active' }" @click="setView('active')">
|
||||
{{ t('adminUsers.viewActive') }}
|
||||
</button>
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'pending' }" @click="setView('pending')">
|
||||
{{ t('adminUsers.viewPending') }}
|
||||
<span v-if="pendingUsers.length" class="users__tab-count">{{ pendingUsers.length }}</span>
|
||||
</button>
|
||||
<button class="users__tab" :class="{ 'users__tab--active': view === 'trashed' }" @click="setView('trashed')">
|
||||
{{ t('adminUsers.viewTrash') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="view === 'active'">
|
||||
<form class="card users__form" @submit.prevent="create">
|
||||
<h2 class="card__title users__form-title">{{ t('adminUsers.newUser') }}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.name') }}</label>
|
||||
<input v-model="createForm.name" required class="field__input" />
|
||||
<p v-if="errors.name" class="field__error">{{ errors.name[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.email') }}</label>
|
||||
<input v-model="createForm.email" type="email" required class="field__input" />
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.password') }}</label>
|
||||
<input v-model="createForm.password" type="password" required class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="createForm.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="users__form-title">
|
||||
<button type="submit" :disabled="creating" class="btn btn--primary">
|
||||
{{ creating ? t('common.creating') : t('adminUsers.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colRole') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colStatus') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<template v-for="user in users" :key="user.id">
|
||||
<tr v-if="editingId !== user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell">{{ user.roles.join(', ') || t('adminUsers.noRole') }}</td>
|
||||
<td class="table__cell">
|
||||
<span class="badge" :class="user.is_active ? 'badge--active' : 'badge--inactive'">
|
||||
<span class="badge__dot"></span>
|
||||
{{ user.is_active ? t('adminUsers.active') : t('adminUsers.inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="startEdit(user)" class="link">{{ t('adminUsers.edit') }}</button>
|
||||
<button v-if="user.id !== auth.user?.id" @click="destroy(user)" class="link link--danger users__row-action">{{ t('adminUsers.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else class="table__row--edit">
|
||||
<td class="table__cell" colspan="5">
|
||||
<div class="users__edit-grid">
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.name') }}</label>
|
||||
<input v-model="editForm.name" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.email') }}</label>
|
||||
<input v-model="editForm.email" type="email" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.newPassword') }}</label>
|
||||
<input v-model="editForm.password" type="password" :placeholder="t('adminUsers.noChange')" class="field__input" />
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="editForm.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field__checkbox-row">
|
||||
<input type="checkbox" v-model="editForm.is_active" class="field__checkbox" />
|
||||
{{ t('adminUsers.active') }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
<div class="users__edit-actions">
|
||||
<button @click="saveEdit(user)" class="btn btn--accent">{{ t('adminUsers.save') }}</button>
|
||||
<button @click="cancelEdit" class="btn btn--secondary">{{ t('adminUsers.cancel') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="users__app-roles">
|
||||
<h3 class="users__app-roles-title">{{ t('adminUsers.appRolesTitle') }}</h3>
|
||||
<p class="users__app-roles-desc">
|
||||
{{ t('adminUsers.appRolesDesc', { name: user.name }) }}
|
||||
</p>
|
||||
|
||||
<div v-if="!appRoles.length" class="users__app-roles-empty">{{ t('adminUsers.noAppRoles') }}</div>
|
||||
<ul v-else class="users__chip-list">
|
||||
<li v-for="group in appRoles" :key="group.client_id" class="users__chip-group">
|
||||
<span v-for="role in group.roles" :key="role" class="chip">
|
||||
{{ group.client_name }} — {{ role }}
|
||||
<button @click="removeAppRole(user, group.client_id, role)" class="chip__remove">✕</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="users__assign-row">
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">App</label>
|
||||
<select v-model="newAppRole.client_id" class="field__select">
|
||||
<option value="">{{ t('adminUsers.chooseApp') }}</option>
|
||||
<option v-for="app in apps" :key="app.id" :value="app.id">{{ app.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--sm">
|
||||
<label class="field__label">{{ t('adminUsers.role') }}</label>
|
||||
<select v-model="newAppRole.role" class="field__select">
|
||||
<option value="user">{{ t('adminUsers.roleUser') }}</option>
|
||||
<option value="admin">{{ t('adminUsers.roleAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="addAppRole(user)" class="btn btn--dark">
|
||||
{{ t('adminUsers.assign') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view === 'pending'">
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!pendingUsers.length" class="table__empty">{{ t('adminUsers.pendingEmpty') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="user in pendingUsers" :key="user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell users__deleted-at">{{ t('adminUsers.registeredAt', { date: formatDate(user.created_at) }) }}</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="approveUser(user)" class="link">{{ t('adminUsers.approve') }}</button>
|
||||
<button @click="rejectUser(user)" class="link link--danger users__row-action">{{ t('adminUsers.reject') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-card users__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!trashedUsers.length" class="table__empty">{{ t('adminUsers.trashEmpty') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colName') }}</th>
|
||||
<th class="table__head-cell">{{ t('adminUsers.colEmail') }}</th>
|
||||
<th class="table__head-cell"></th>
|
||||
<th class="table__head-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="user in trashedUsers" :key="user.id">
|
||||
<td class="table__cell table__cell--primary">{{ user.name }}</td>
|
||||
<td class="table__cell">{{ user.email }}</td>
|
||||
<td class="table__cell users__deleted-at">{{ t('adminUsers.deletedAt', { date: formatDate(user.deleted_at!) }) }}</td>
|
||||
<td class="table__cell table__cell--actions">
|
||||
<button @click="restoreUser(user)" class="link">{{ t('adminUsers.restore') }}</button>
|
||||
<button @click="forceDeleteUser(user)" class="link link--danger users__row-action">{{ t('adminUsers.forceDelete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.users__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.users__tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users__tab--active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.users__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.users__form {
|
||||
margin-top: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users__form-title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.users__table-card {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.users__row-action {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.users__edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.users__edit-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.users__app-roles {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.users__app-roles-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.users__app-roles-desc {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users__app-roles-empty {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users__chip-list {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users__chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.users__assign-row {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users__deleted-at {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.users__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.users__edit-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
resources/js/router/index.ts
Normal file
96
resources/js/router/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean;
|
||||
requiresAdmin?: boolean;
|
||||
guestOnly?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: () => import('../pages/Dashboard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../pages/Login.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../pages/Register.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
name: 'clients',
|
||||
component: () => import('../pages/Clients.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/tokens',
|
||||
name: 'tokens',
|
||||
component: () => import('../pages/Tokens.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/authorized-apps',
|
||||
name: 'authorized-apps',
|
||||
component: () => import('../pages/AuthorizedApps.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/oauth/authorize',
|
||||
name: 'oauth-authorize',
|
||||
component: () => import('../pages/Authorize.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
component: () => import('../pages/admin/Users.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/analytics',
|
||||
name: 'admin-analytics',
|
||||
component: () => import('../pages/admin/Analytics.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.fetchUser();
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) {
|
||||
return { name: 'dashboard' };
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && auth.isAuthenticated) {
|
||||
return { name: 'dashboard' };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
91
resources/js/stores/auth.ts
Normal file
91
resources/js/stores/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import type { User } from '../types/models';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface Credentials {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
interface RegisterPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
interface RegisterResult {
|
||||
bootstrap: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
user: null,
|
||||
checked: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => state.user !== null,
|
||||
isAdmin: (state): boolean => Boolean(state.user?.roles?.includes('admin')),
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchUser(): Promise<void> {
|
||||
try {
|
||||
const { data } = await axios.get<{ user: User }>('/web-api/me');
|
||||
this.user = data.user;
|
||||
} catch {
|
||||
this.user = null;
|
||||
} finally {
|
||||
this.checked = true;
|
||||
}
|
||||
},
|
||||
|
||||
async login(payload: Credentials): Promise<string | null> {
|
||||
const { data } = await axios.post<{ user: User; intended: string | null }>('/web-api/login', payload);
|
||||
this.user = data.user;
|
||||
this.checked = true;
|
||||
|
||||
return data.intended ?? null;
|
||||
},
|
||||
|
||||
// Si todavía no hay ningún usuario en el sistema, el registro loguea de
|
||||
// una como admin (bootstrap). Si ya hay usuarios, la cuenta queda
|
||||
// pendiente hasta que un admin la aprueba (ver AuthController::register).
|
||||
async register(payload: RegisterPayload): Promise<RegisterResult> {
|
||||
const { data } = await axios.post<{
|
||||
bootstrap?: true;
|
||||
user?: User;
|
||||
pending?: true;
|
||||
message?: string;
|
||||
}>('/web-api/register', payload);
|
||||
|
||||
if (data.bootstrap && data.user) {
|
||||
this.user = data.user;
|
||||
this.checked = true;
|
||||
|
||||
return { bootstrap: true };
|
||||
}
|
||||
|
||||
return { bootstrap: false, message: data.message };
|
||||
},
|
||||
|
||||
async setupIsEmpty(): Promise<boolean> {
|
||||
const { data } = await axios.get<{ empty: boolean }>('/web-api/setup-status');
|
||||
|
||||
return data.empty;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await axios.post('/web-api/logout');
|
||||
this.user = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
90
resources/js/types/models.ts
Normal file
90
resources/js/types/models.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
roles: string[];
|
||||
permissions?: string[];
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
approved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface OAuthClient {
|
||||
id: string;
|
||||
owner_id: number;
|
||||
owner_type: string;
|
||||
name: string;
|
||||
secret: string | null;
|
||||
provider: string | null;
|
||||
redirect_uris: string[];
|
||||
grant_types: string[];
|
||||
revoked: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PersonalAccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface AuthorizedAppToken {
|
||||
id: string;
|
||||
user_id: number;
|
||||
client_id: string;
|
||||
name: string | null;
|
||||
scopes: string[];
|
||||
revoked: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
client: { id: string; name: string };
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OAuthApp {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AppRoleGroup {
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
kpis: {
|
||||
total_users: number;
|
||||
active_sessions: number;
|
||||
logins_today: number;
|
||||
logins_7d: number;
|
||||
};
|
||||
logins_by_day: Array<{ date: string; session: number; oauth: number }>;
|
||||
logins_by_app: Array<{ client_name: string; count: number }>;
|
||||
top_users: Array<{ name: string; email: string; count: number }>;
|
||||
recent: Array<{
|
||||
user_name: string | null;
|
||||
user_email: string | null;
|
||||
client_name: string;
|
||||
type: 'session' | 'oauth';
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizePayload {
|
||||
authToken: string;
|
||||
csrfToken: string;
|
||||
client: { id: string; name: string };
|
||||
scopes: Scope[];
|
||||
user: { name: string; email: string };
|
||||
}
|
||||
13
resources/js/vite-env.d.ts
vendored
Normal file
13
resources/js/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { OAuthAuthorizePayload } from './types/models';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LOCALE__: 'es' | 'en' | 'ca';
|
||||
__SESSION_LOCALE__: string;
|
||||
__OAUTH_AUTHORIZE__?: OAuthAuthorizePayload;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
35
resources/scss/abstracts/_mixins.scss
Normal file
35
resources/scss/abstracts/_mixins.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
// Equivalente al prefijo `dark:` de Tailwind: el toggle de tema pone la clase
|
||||
// `.dark` en <html> (ver resources/js/composables/useTheme.ts), así que
|
||||
// cualquier regla dentro de este mixin solo aplica bajo esa clase ancestro.
|
||||
// Uso en los partials globales de components/ (no pasan por el compilador
|
||||
// "scoped" de Vue, así que acá ".dark &" alcanza). Los dos componentes que
|
||||
// SÍ usan <style scoped> (DailyLoginsChart, Analytics) escriben
|
||||
// ":global(.dark) &" directo en vez de este mixin — ver nota en esos archivos.
|
||||
@mixin dark {
|
||||
.dark & {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin focus-ring {
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin respond-up($breakpoint) {
|
||||
@media (min-width: $breakpoint) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
32
resources/scss/abstracts/_variables.scss
Normal file
32
resources/scss/abstracts/_variables.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
// Escala de espaciado (coincide con la que ya se usaba vía utilities de Tailwind).
|
||||
$space-1: 0.25rem; // 4px
|
||||
$space-2: 0.5rem; // 8px
|
||||
$space-3: 0.75rem; // 12px
|
||||
$space-4: 1rem; // 16px
|
||||
$space-5: 1.25rem; // 20px
|
||||
$space-6: 1.5rem; // 24px
|
||||
$space-8: 2rem; // 32px
|
||||
$space-10: 2.5rem; // 40px
|
||||
|
||||
$radius-sm: 0.375rem; // 6px
|
||||
$radius-md: 0.5rem; // 8px
|
||||
$radius-lg: 0.75rem; // 12px
|
||||
$radius-xl: 1rem; // 16px
|
||||
$radius-2xl: 1.25rem; // 20px
|
||||
$radius-full: 9999px;
|
||||
|
||||
$font-xs: 0.75rem;
|
||||
$font-sm: 0.875rem;
|
||||
$font-base: 1rem;
|
||||
$font-lg: 1.125rem;
|
||||
$font-xl: 1.25rem;
|
||||
$font-2xl: 1.5rem;
|
||||
|
||||
$page-max-width: 72rem; // 1152px, equivalente a max-w-6xl
|
||||
$auth-card-width: 24rem; // 384px, equivalente a max-w-sm
|
||||
|
||||
$breakpoint-sm: 640px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 1024px;
|
||||
|
||||
$transition-fast: 150ms ease;
|
||||
18
resources/scss/app.scss
Normal file
18
resources/scss/app.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@use 'base/tokens';
|
||||
@use 'base/reset';
|
||||
@use 'base/typography';
|
||||
|
||||
@use 'components/page-header';
|
||||
@use 'components/button';
|
||||
@use 'components/link';
|
||||
@use 'components/card';
|
||||
@use 'components/field';
|
||||
@use 'components/table';
|
||||
@use 'components/badge';
|
||||
@use 'components/banner';
|
||||
@use 'components/pill-select';
|
||||
@use 'components/chip';
|
||||
@use 'components/stat-tile';
|
||||
@use 'components/bar-list';
|
||||
@use 'components/auth-card';
|
||||
@use 'components/app-nav';
|
||||
50
resources/scss/base/_reset.scss
Normal file
50
resources/scss/base/_reset.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
// Reset mínimo moderno — sin Tailwind Preflight, hace falta esto a mano.
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
70
resources/scss/base/_tokens.scss
Normal file
70
resources/scss/base/_tokens.scss
Normal file
@@ -0,0 +1,70 @@
|
||||
// Tokens de color como custom properties CSS (no variables SCSS): así el
|
||||
// toggle de tema (clase .dark en <html>) los puede resolver en runtime sin
|
||||
// recompilar nada — SCSS solo aporta el anidado/organización de las reglas.
|
||||
:root {
|
||||
--color-page-bg: #f9fafb; // gray-50
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #f9fafb; // gray-50
|
||||
--color-code-bg: #f3f4f6; // gray-100
|
||||
--color-border: #e5e7eb; // gray-200
|
||||
--color-border-strong: #d1d5db; // gray-300
|
||||
|
||||
--color-text-primary: #111827; // gray-900
|
||||
--color-text-secondary: #6b7280; // gray-500
|
||||
--color-text-muted: #9ca3af; // gray-400
|
||||
--color-text-on-accent: #ffffff;
|
||||
|
||||
--color-accent: #4f46e5; // indigo-600
|
||||
--color-accent-strong: #6366f1; // indigo-500
|
||||
--color-accent-soft: #eef2ff; // indigo-50
|
||||
--color-accent-soft-text: #4338ca; // indigo-700
|
||||
--gradient-accent: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||
--gradient-glow: linear-gradient(to bottom right, rgb(129 140 248 / 30%), rgb(167 139 250 / 20%));
|
||||
|
||||
--color-success: #059669; // emerald-600
|
||||
--color-success-dot: #10b981; // emerald-500
|
||||
--color-danger: #dc2626; // red-600
|
||||
|
||||
--color-warning-border: #fcd34d; // amber-300
|
||||
--color-warning-bg: #fffbeb; // amber-50
|
||||
--color-warning-title: #92400e; // amber-800
|
||||
--color-warning-text: #78350f; // amber-900
|
||||
--color-warning-code-bg: #ffffff;
|
||||
|
||||
--shadow-card: 0 1px 2px 0 rgb(0 0 0 / 4%);
|
||||
--shadow-elevated: 0 10px 25px -5px rgb(17 24 39 / 8%), 0 8px 10px -6px rgb(17 24 39 / 5%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-page-bg: #09090b; // zinc-950
|
||||
--color-surface: #18181b; // zinc-900
|
||||
--color-surface-muted: rgb(255 255 255 / 5%);
|
||||
--color-code-bg: rgb(255 255 255 / 10%);
|
||||
--color-border: rgb(255 255 255 / 10%);
|
||||
--color-border-strong: rgb(255 255 255 / 15%);
|
||||
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #9ca3af; // gray-400
|
||||
--color-text-muted: #6b7280; // gray-500
|
||||
--color-text-on-accent: #ffffff;
|
||||
|
||||
--color-accent: #6366f1; // indigo-500
|
||||
--color-accent-strong: #818cf8; // indigo-400
|
||||
--color-accent-soft: rgb(99 102 241 / 15%);
|
||||
--color-accent-soft-text: #a5b4fc; // indigo-300
|
||||
--gradient-accent: linear-gradient(to right, #4f46e5, #7c3aed);
|
||||
--gradient-glow: linear-gradient(to bottom right, rgb(99 102 241 / 20%), rgb(139 92 246 / 10%));
|
||||
|
||||
--color-success: #34d399; // emerald-400
|
||||
--color-success-dot: #10b981;
|
||||
--color-danger: #f87171; // red-400
|
||||
|
||||
--color-warning-border: rgb(245 158 11 / 30%);
|
||||
--color-warning-bg: rgb(245 158 11 / 10%);
|
||||
--color-warning-title: #fcd34d; // amber-300
|
||||
--color-warning-text: #fde68a; // amber-200
|
||||
--color-warning-code-bg: rgb(0 0 0 / 20%);
|
||||
|
||||
--shadow-card: none;
|
||||
--shadow-elevated: 0 20px 25px -5px rgb(0 0 0 / 30%), 0 8px 10px -6px rgb(0 0 0 / 30%);
|
||||
}
|
||||
26
resources/scss/base/_typography.scss
Normal file
26
resources/scss/base/_typography.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
// La familia tipográfica la sirve el plugin `laravel-vite-plugin/fonts`
|
||||
// (ver vite.config.ts) con su propio @font-face, independiente de este archivo.
|
||||
body {
|
||||
font-family:
|
||||
'Instrument Sans',
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-color: var(--color-page-bg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
background: var(--color-code-bg);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
131
resources/scss/components/_app-nav.scss
Normal file
131
resources/scss/components/_app-nav.scss
Normal file
@@ -0,0 +1,131 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.app-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: rgb(255 255 255 / 80%);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
@include dark {
|
||||
background: rgb(9 9 11 / 80%);
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__inner {
|
||||
max-width: $page-max-width;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-4;
|
||||
padding: $space-3 $space-6;
|
||||
}
|
||||
|
||||
.app-nav__section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-6;
|
||||
}
|
||||
|
||||
.app-nav__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.app-nav__logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
border-radius: $radius-md;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-nav__brand-name {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.app-nav__links {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: $space-1;
|
||||
|
||||
@include respond-up($breakpoint-md) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__link {
|
||||
border-radius: $radius-full;
|
||||
padding: $space-1 * 1.5 $space-3;
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
transition: $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-soft-text);
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.app-nav__theme-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
color: var(--color-text-secondary);
|
||||
transition: $transition-fast;
|
||||
|
||||
@include dark {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.app-nav__user {
|
||||
display: none;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@include respond-up($breakpoint-sm) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: $page-max-width;
|
||||
margin: 0 auto;
|
||||
padding: $space-10 $space-6;
|
||||
}
|
||||
130
resources/scss/components/_auth-card.scss
Normal file
130
resources/scss/components/_auth-card.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.auth-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 0 $space-4;
|
||||
background: var(--color-page-bg);
|
||||
}
|
||||
|
||||
.auth-page__glow {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -8rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 20rem;
|
||||
width: 36rem;
|
||||
border-radius: $radius-full;
|
||||
background: var(--gradient-glow);
|
||||
filter: blur(64px);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: $auth-card-width;
|
||||
border-radius: $radius-2xl;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
padding: $space-8;
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
|
||||
.auth-card--wide {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.auth-card__logo {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 2.25rem;
|
||||
width: 2.25rem;
|
||||
border-radius: $radius-lg;
|
||||
background: var(--gradient-accent);
|
||||
color: #fff;
|
||||
font-size: $font-xs;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 1px 2px 0 rgb(79 70 229 / 20%);
|
||||
}
|
||||
|
||||
.auth-card__title {
|
||||
margin-top: $space-4;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.auth-card__subtitle {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-card__form {
|
||||
margin-top: $space-6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.auth-card__footer {
|
||||
margin-top: $space-5;
|
||||
text-align: center;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-card__banner {
|
||||
margin-top: $space-4;
|
||||
border-radius: $radius-xl;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
padding: $space-3;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-card__hint {
|
||||
margin-top: $space-2;
|
||||
text-align: center;
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.auth-card__scopes {
|
||||
margin-top: $space-4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
border-radius: $radius-xl;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
padding: $space-3;
|
||||
}
|
||||
|
||||
.auth-card__scope {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.auth-card__scope-check {
|
||||
color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.auth-card__actions {
|
||||
margin-top: $space-6;
|
||||
display: flex;
|
||||
gap: $space-3;
|
||||
|
||||
form {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
24
resources/scss/components/_badge.scss
Normal file
24
resources/scss/components/_badge.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-1 * 1.5;
|
||||
font-size: $font-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge__dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: $radius-full;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.badge--active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge--inactive {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
36
resources/scss/components/_banner.scss
Normal file
36
resources/scss/components/_banner.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.banner {
|
||||
margin-top: $space-6;
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-4;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
|
||||
.banner--warning {
|
||||
border: 1px solid var(--color-warning-border);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.banner__title {
|
||||
font-weight: 500;
|
||||
color: var(--color-warning-title);
|
||||
}
|
||||
|
||||
.banner__line {
|
||||
margin-top: $space-1;
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.banner__code {
|
||||
background: var(--color-warning-code-bg);
|
||||
border-radius: $radius-sm;
|
||||
padding: 0 $space-1;
|
||||
}
|
||||
|
||||
.banner__close {
|
||||
margin-top: $space-2;
|
||||
color: var(--color-warning-title);
|
||||
text-decoration: underline;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
68
resources/scss/components/_bar-list.scss
Normal file
68
resources/scss/components/_bar-list.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.bar-list {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-5;
|
||||
}
|
||||
|
||||
.bar-list__title {
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.bar-list__empty {
|
||||
margin-top: $space-3;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.bar-list__items {
|
||||
margin-top: $space-4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.bar-list__row-head {
|
||||
margin-bottom: $space-1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: $font-xs;
|
||||
}
|
||||
|
||||
.bar-list__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.bar-list__value {
|
||||
margin-left: $space-2;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bar-list__track {
|
||||
height: 0.875rem;
|
||||
width: 100%;
|
||||
border-radius: $radius-full;
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.bar-list__fill {
|
||||
height: 0.875rem;
|
||||
border-radius: 0 $radius-full $radius-full 0;
|
||||
background: var(--series-1, var(--color-accent));
|
||||
transition: filter 100ms;
|
||||
}
|
||||
|
||||
.bar-list__row:hover .bar-list__fill,
|
||||
.bar-list__row:focus-within .bar-list__fill {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
107
resources/scss/components/_button.scss
Normal file
107
resources/scss/components/_button.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $space-2;
|
||||
padding: $space-2 $space-4;
|
||||
border-radius: $radius-lg;
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
transition: $transition-fast;
|
||||
white-space: nowrap;
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--pill {
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--gradient-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
box-shadow: 0 1px 2px 0 rgb(79 70 229 / 20%);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
// Variante sólida (no-gradiente) del acento, para acciones dentro de tarjetas
|
||||
// donde el gradiente sería demasiado.
|
||||
.btn--accent {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-on-accent);
|
||||
border-radius: $radius-md;
|
||||
padding: $space-2 $space-4;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-accent-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
@include dark {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Botón oscuro/claro invertido (usado para "Salir" en la nav).
|
||||
.btn--invert {
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
border-radius: $radius-full;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
@include dark {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Botón oscuro sólido (asignar rol dentro de Usuarios).
|
||||
.btn--dark {
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
border-radius: $radius-md;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
@include dark {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
resources/scss/components/_card.scss
Normal file
35
resources/scss/components/_card.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-5;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card__subtitle {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
// Tarjetas-link del dashboard: hover con leve elevación.
|
||||
.card--interactive {
|
||||
display: block;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
border-color $transition-fast,
|
||||
box-shadow $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-accent-strong);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
}
|
||||
21
resources/scss/components/_chip.scss
Normal file
21
resources/scss/components/_chip.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-1;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent-soft-text);
|
||||
border-radius: $radius-full;
|
||||
padding: $space-1 $space-2 * 1.25;
|
||||
font-size: $font-xs;
|
||||
}
|
||||
|
||||
.chip__remove {
|
||||
color: var(--color-accent-strong);
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
80
resources/scss/components/_field.scss
Normal file
80
resources/scss/components/_field.scss
Normal file
@@ -0,0 +1,80 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: $space-1;
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.field__error {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.field__input,
|
||||
.field__select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: $radius-md;
|
||||
padding: $space-2 $space-3;
|
||||
font-size: $font-sm;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
@include dark {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent-strong);
|
||||
box-shadow: 0 0 0 3px rgb(99 102 241 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
.field--sm {
|
||||
.field__label {
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.field__input,
|
||||
.field__select {
|
||||
padding: $space-1 * 1.5 $space-2;
|
||||
}
|
||||
}
|
||||
|
||||
.field__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.field__checkbox {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: $radius-sm;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
28
resources/scss/components/_link.scss
Normal file
28
resources/scss/components/_link.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.link {
|
||||
color: var(--color-accent-strong);
|
||||
font-weight: 500;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.link--danger {
|
||||
color: var(--color-danger);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.link--muted {
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
20
resources/scss/components/_page-header.scss
Normal file
20
resources/scss/components/_page-header.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-top: $space-1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
18
resources/scss/components/_pill-select.scss
Normal file
18
resources/scss/components/_pill-select.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.pill-select {
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: $radius-full;
|
||||
padding: $space-1 * 1.5 $space-3;
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
|
||||
@include dark {
|
||||
border-color: var(--color-border);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
26
resources/scss/components/_stat-tile.scss
Normal file
26
resources/scss/components/_stat-tile.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
.stat-tile {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-4;
|
||||
}
|
||||
|
||||
.stat-tile__label {
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-tile__value {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stat-tile__hint {
|
||||
margin-top: $space-1;
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
62
resources/scss/components/_table.scss
Normal file
62
resources/scss/components/_table.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.table-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: $radius-xl;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
|
||||
.table__head {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.table__head-cell {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
padding: $space-2 $space-4;
|
||||
}
|
||||
|
||||
.table__body {
|
||||
tr {
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
}
|
||||
|
||||
tr.table__row--edit:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.table__row--edit {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.table__cell {
|
||||
padding: $space-2 $space-4;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&--primary {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&--actions {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.table__empty {
|
||||
padding: $space-4;
|
||||
font-size: $font-sm;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
56
resources/views/app.blade.php
Normal file
56
resources/views/app.blade.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OAuth2 Server</title>
|
||||
<script>
|
||||
// Aplica el tema ANTES del primer paint (evita el flash del tema equivocado).
|
||||
// Prioridad: preferencia guardada > prefers-color-scheme del SO.
|
||||
(function () {
|
||||
var stored = localStorage.getItem('theme');
|
||||
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
})();
|
||||
</script>
|
||||
@vite(['resources/scss/app.scss', 'resources/js/app.ts'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@php
|
||||
$sessionLocale = app()->getLocale();
|
||||
// La SPA trae diccionario propio para "es", "en" y "ca".
|
||||
$frontendLocale = in_array($sessionLocale, ['es', 'en', 'ca'], true) ? $sessionLocale : 'es';
|
||||
@endphp
|
||||
<script>
|
||||
// Locale de sesión real (es/en/ca) — para preseleccionar el selector de idioma.
|
||||
window.__SESSION_LOCALE__ = @json($sessionLocale);
|
||||
window.__LOCALE__ = @json($frontendLocale);
|
||||
</script>
|
||||
|
||||
@isset($authToken)
|
||||
@php
|
||||
$oauthAuthorizePayload = [
|
||||
'authToken' => $authToken,
|
||||
'csrfToken' => csrf_token(),
|
||||
'client' => [
|
||||
'id' => $client->id,
|
||||
'name' => $client->name,
|
||||
],
|
||||
'scopes' => collect($scopes)->map(fn ($scope) => [
|
||||
'id' => $scope->id,
|
||||
'description' => $scope->description,
|
||||
])->values(),
|
||||
'user' => [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
<script>
|
||||
window.__OAUTH_AUTHORIZE__ = @json($oauthAuthorizePayload);
|
||||
</script>
|
||||
@endisset
|
||||
</body>
|
||||
</html>
|
||||
0
resources/views/vendor/l5-swagger/.gitkeep
vendored
Normal file
0
resources/views/vendor/l5-swagger/.gitkeep
vendored
Normal file
174
resources/views/vendor/l5-swagger/index.blade.php
vendored
Normal file
174
resources/views/vendor/l5-swagger/index.blade.php
vendored
Normal file
@@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ $documentationTitle }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ l5_swagger_asset($documentation, 'swagger-ui.css') }}">
|
||||
<link rel="icon" type="image/png" href="{{ l5_swagger_asset($documentation, 'favicon-32x32.png') }}" sizes="32x32"/>
|
||||
<link rel="icon" type="image/png" href="{{ l5_swagger_asset($documentation, 'favicon-16x16.png') }}" sizes="16x16"/>
|
||||
<style>
|
||||
html
|
||||
{
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
*,
|
||||
*:before,
|
||||
*:after
|
||||
{
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin:0;
|
||||
background: #fafafa;
|
||||
}
|
||||
</style>
|
||||
@if(config('l5-swagger.defaults.ui.display.dark_mode'))
|
||||
<style>
|
||||
body#dark-mode,
|
||||
#dark-mode .scheme-container {
|
||||
background: #1b1b1b;
|
||||
}
|
||||
#dark-mode .scheme-container,
|
||||
#dark-mode .opblock .opblock-section-header{
|
||||
box-shadow: 0 1px 2px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
#dark-mode .operation-filter-input,
|
||||
#dark-mode .dialog-ux .modal-ux,
|
||||
#dark-mode input[type=email],
|
||||
#dark-mode input[type=file],
|
||||
#dark-mode input[type=password],
|
||||
#dark-mode input[type=search],
|
||||
#dark-mode input[type=text],
|
||||
#dark-mode textarea{
|
||||
background: #343434;
|
||||
color: #e7e7e7;
|
||||
}
|
||||
#dark-mode .title,
|
||||
#dark-mode li,
|
||||
#dark-mode p,
|
||||
#dark-mode table,
|
||||
#dark-mode label,
|
||||
#dark-mode .opblock-tag,
|
||||
#dark-mode .opblock .opblock-summary-operation-id,
|
||||
#dark-mode .opblock .opblock-summary-path,
|
||||
#dark-mode .opblock .opblock-summary-path__deprecated,
|
||||
#dark-mode h1,
|
||||
#dark-mode h2,
|
||||
#dark-mode h3,
|
||||
#dark-mode h4,
|
||||
#dark-mode h5,
|
||||
#dark-mode .btn,
|
||||
#dark-mode .tab li,
|
||||
#dark-mode .parameter__name,
|
||||
#dark-mode .parameter__type,
|
||||
#dark-mode .prop-format,
|
||||
#dark-mode .loading-container .loading:after{
|
||||
color: #e7e7e7;
|
||||
}
|
||||
#dark-mode .opblock-description-wrapper p,
|
||||
#dark-mode .opblock-external-docs-wrapper p,
|
||||
#dark-mode .opblock-title_normal p,
|
||||
#dark-mode .response-col_status,
|
||||
#dark-mode table thead tr td,
|
||||
#dark-mode table thead tr th,
|
||||
#dark-mode .response-col_links,
|
||||
#dark-mode .swagger-ui{
|
||||
color: wheat;
|
||||
}
|
||||
#dark-mode .parameter__extension,
|
||||
#dark-mode .parameter__in,
|
||||
#dark-mode .model-title{
|
||||
color: #949494;
|
||||
}
|
||||
#dark-mode table thead tr td,
|
||||
#dark-mode table thead tr th{
|
||||
border-color: rgba(120,120,120,.2);
|
||||
}
|
||||
#dark-mode .opblock .opblock-section-header{
|
||||
background: transparent;
|
||||
}
|
||||
#dark-mode .opblock.opblock-post{
|
||||
background: rgba(73,204,144,.25);
|
||||
}
|
||||
#dark-mode .opblock.opblock-get{
|
||||
background: rgba(97,175,254,.25);
|
||||
}
|
||||
#dark-mode .opblock.opblock-put{
|
||||
background: rgba(252,161,48,.25);
|
||||
}
|
||||
#dark-mode .opblock.opblock-delete{
|
||||
background: rgba(249,62,62,.25);
|
||||
}
|
||||
#dark-mode .loading-container .loading:before{
|
||||
border-color: rgba(255,255,255,10%);
|
||||
border-top-color: rgba(255,255,255,.6);
|
||||
}
|
||||
#dark-mode svg:not(:root){
|
||||
fill: #e7e7e7;
|
||||
}
|
||||
#dark-mode .opblock-summary-description {
|
||||
color: #fafafa;
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
</head>
|
||||
|
||||
<body @if(config('l5-swagger.defaults.ui.display.dark_mode')) id="dark-mode" @endif>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="{{ l5_swagger_asset($documentation, 'swagger-ui-bundle.js') }}"></script>
|
||||
<script src="{{ l5_swagger_asset($documentation, 'swagger-ui-standalone-preset.js') }}"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
const urls = [];
|
||||
|
||||
@foreach($urlsToDocs as $title => $url)
|
||||
urls.push({name: "{{ $title }}", url: "{{ $url }}"});
|
||||
@endforeach
|
||||
|
||||
// Build a system
|
||||
const ui = SwaggerUIBundle({
|
||||
dom_id: '#swagger-ui',
|
||||
urls: urls,
|
||||
"urls.primaryName": "{{ $documentationTitle }}",
|
||||
operationsSorter: {!! isset($operationsSorter) ? '"' . $operationsSorter . '"' : 'null' !!},
|
||||
configUrl: {!! isset($configUrl) ? '"' . $configUrl . '"' : 'null' !!},
|
||||
validatorUrl: {!! isset($validatorUrl) ? '"' . $validatorUrl . '"' : 'null' !!},
|
||||
oauth2RedirectUrl: "{{ route('l5-swagger.'.$documentation.'.oauth2_callback', [], $useAbsolutePath) }}",
|
||||
|
||||
requestInterceptor: function(request) {
|
||||
request.headers['X-CSRF-TOKEN'] = '{{ csrf_token() }}';
|
||||
return request;
|
||||
},
|
||||
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
|
||||
layout: "StandaloneLayout",
|
||||
docExpansion : "{!! config('l5-swagger.defaults.ui.display.doc_expansion', 'none') !!}",
|
||||
deepLinking: true,
|
||||
filter: {!! config('l5-swagger.defaults.ui.display.filter') ? 'true' : 'false' !!},
|
||||
persistAuthorization: "{!! config('l5-swagger.defaults.ui.authorization.persist_authorization') ? 'true' : 'false' !!}",
|
||||
|
||||
})
|
||||
|
||||
window.ui = ui
|
||||
|
||||
@if(in_array('oauth2', array_column(config('l5-swagger.defaults.securityDefinitions.securitySchemes'), 'type')))
|
||||
ui.initOAuth({
|
||||
usePkceWithAuthorizationCodeGrant: "{!! (bool)config('l5-swagger.defaults.ui.authorization.oauth2.use_pkce_with_authorization_code_grant') !!}"
|
||||
})
|
||||
@endif
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user