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:
2026-07-26 01:02:41 +02:00
commit c2b5291a74
174 changed files with 29318 additions and 0 deletions

107
resources/js/App.vue Normal file
View 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
View 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
View 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;

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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;

View 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;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }} &mdash; {{ 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>

View 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>

View 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>

View 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;

View 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;
},
},
});

View 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
View 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 {};

View 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;
}
}

View 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
View 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';

View 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%;
}

View 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%);
}

View 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;
}

View 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;
}

View 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;
}
}

View 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);
}

View 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;
}

View 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);
}

View 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;
}
}
}

View 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);
}
}

View 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;
}
}

View 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);
}

View 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);
}
}

View 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);
}

View 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);
}
}

View 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);
}

View 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);
}

View 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>

View File

View 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>