new funcionality
This commit is contained in:
BIN
resources/images/logo-black.png
Normal file
BIN
resources/images/logo-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
resources/images/logo-symbol.png
Normal file
BIN
resources/images/logo-symbol.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
resources/images/logo-white.png
Normal file
BIN
resources/images/logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useTheme } from './composables/useTheme';
|
||||
import AppLogo from './components/AppLogo.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
@@ -40,56 +41,58 @@ const navLinks = computed<NavLink[]>(() => [
|
||||
{ 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') },
|
||||
{ to: '/security', name: 'security', label: t('nav.security') },
|
||||
...(auth.isAdmin ? [{ to: '/admin/users', name: 'admin-users', label: t('nav.users') }] : []),
|
||||
...(auth.isAdmin ? [{ to: '/admin/analytics', name: 'admin-analytics', label: t('nav.analytics') }] : []),
|
||||
...(auth.isAdmin ? [{ to: '/admin/audit-log', name: 'admin-audit-log', label: t('nav.auditLog') }] : []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<nav v-if="showNav" class="app-nav">
|
||||
<nav v-if="showNav" class="app-nav no-print">
|
||||
<div class="app-nav__inner">
|
||||
<div class="app-nav__section">
|
||||
<div class="app-nav__top">
|
||||
<router-link to="/" class="app-nav__brand">
|
||||
<span class="app-nav__logo">O2</span>
|
||||
<AppLogo class="app-nav__logo" />
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
</nav>
|
||||
|
||||
7
resources/js/components/AppLogo.vue
Normal file
7
resources/js/components/AppLogo.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import logoSymbol from '../../images/logo-symbol.png';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="logoSymbol" alt="OAuth2 Server" class="app-logo" />
|
||||
</template>
|
||||
@@ -6,8 +6,10 @@ const ca: MessageSchema = {
|
||||
clients: 'Clients OAuth',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Apps autoritzades',
|
||||
security: 'Seguretat',
|
||||
users: 'Usuaris',
|
||||
analytics: 'Analítiques',
|
||||
auditLog: 'Audit log',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Sortir',
|
||||
},
|
||||
@@ -32,6 +34,34 @@ const ca: MessageSchema = {
|
||||
noAccount: 'No tens compte?',
|
||||
signUp: "Registra't",
|
||||
invalidCredentials: 'Credencials no vàlides.',
|
||||
twoFactorTitle: 'Verificació en dos passos',
|
||||
twoFactorSubtitle: "Introdueix el codi de la teva app autenticadora (o un codi de recuperació).",
|
||||
twoFactorCode: 'Codi',
|
||||
twoFactorSubmit: 'Verificar',
|
||||
invalidTwoFactorCode: 'Codi no vàlid.',
|
||||
backToLogin: 'Tornar a entrar',
|
||||
forgotPassword: 'Has oblidat la contrasenya?',
|
||||
},
|
||||
forgotPassword: {
|
||||
title: 'Restablir contrasenya',
|
||||
subtitle: "Introdueix el teu email i t'enviarem un enllaç per triar una contrasenya nova.",
|
||||
email: 'Email',
|
||||
submit: 'Enviar enllaç',
|
||||
submitting: 'Enviant…',
|
||||
sent: "Si existeix un compte amb aquest email, t'hem enviat un enllaç per restablir la contrasenya. Revisa la teva bústia.",
|
||||
backToLogin: 'Tornar a entrar',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Tria una contrasenya nova',
|
||||
subtitle: 'Gairebé — confirma la contrasenya nova per al teu compte.',
|
||||
password: 'Contrasenya nova',
|
||||
passwordConfirm: 'Confirmar contrasenya',
|
||||
submit: 'Restablir contrasenya',
|
||||
submitting: 'Restablint…',
|
||||
success: "La teva contrasenya s'ha restablert. Ja pots entrar amb la nova — hem tancat les sessions actives per seguretat.",
|
||||
goToLogin: 'Anar a entrar',
|
||||
invalidLink: 'Aquest enllaç no és vàlid o ha caducat. Demana\'n un de nou.',
|
||||
missingParams: 'Falta informació a l\'enllaç. Demana\'n un de nou des de "Has oblidat la contrasenya?".',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear compte',
|
||||
@@ -58,6 +88,8 @@ const ca: MessageSchema = {
|
||||
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.',
|
||||
securityTitle: 'Seguretat',
|
||||
securityDesc: 'Activa la verificació en dos passos (2FA) per al teu compte.',
|
||||
usersTitle: 'Usuaris',
|
||||
usersDesc: 'Alta, edició de rol/estat i baixa de comptes (només admins).',
|
||||
analyticsTitle: 'Analítiques',
|
||||
@@ -85,6 +117,32 @@ const ca: MessageSchema = {
|
||||
revokeConfirm: 'Vols revocar el client "{name}"? Els seus tokens deixaran de funcionar.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
webhooksTitle: 'Webhooks',
|
||||
hideWebhooks: 'Amagar webhooks',
|
||||
webhooksDesc: "Notificacions sortints a la teva app quan passa alguna cosa rellevant — sense haver de fer polling.",
|
||||
webhookSecretWarning: 'Guarda aquest secret ara, no es podrà tornar a veure. Fes-lo servir per verificar la signatura HMAC-SHA256 a la capçalera X-Webhook-Signature.',
|
||||
webhookSecretLabel: 'secret:',
|
||||
webhookPaused: 'pausat',
|
||||
lastDelivery: 'últim enviament',
|
||||
pending: 'pendent',
|
||||
pause: 'Pausar',
|
||||
resume: 'Reprendre',
|
||||
viewHistory: 'Veure historial',
|
||||
hideHistory: 'Amagar historial',
|
||||
noDeliveries: 'Encara no hi ha hagut entregues per a aquest webhook.',
|
||||
attempts: 'intents: {count}',
|
||||
retryDelivery: 'Reintentar',
|
||||
retrying: 'Reintentant…',
|
||||
noWebhooks: 'Encara no has registrat cap webhook per a aquesta app.',
|
||||
webhookUrl: 'URL del webhook',
|
||||
addWebhook: 'Afegir webhook',
|
||||
webhookDeleteConfirm: 'Vols esborrar el webhook "{url}"?',
|
||||
eventTokenRevoked: 'Token revocat',
|
||||
eventUserDeactivated: 'Usuari desactivat',
|
||||
rotateSecret: 'Rotar secret',
|
||||
rotateSecretConfirm: 'Vols rotar el secret de "{name}"? El secret actual seguirà funcionant 48 hores més per no tallar integracions que encara no s\'han actualitzat.',
|
||||
rotateSecretFailed: 'No s\'ha pogut rotar el secret.',
|
||||
previousSecretStillValid: 'El secret anterior segueix sent vàlid fins al {date}.',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
@@ -169,6 +227,14 @@ const ca: MessageSchema = {
|
||||
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.",
|
||||
sessionsTitle: 'Sessions actives',
|
||||
sessionsDesc: "Tokens OAuth vigents d'aquest usuari — apps amb accés al seu compte ara mateix.",
|
||||
noSessions: 'No hi ha sessions actives.',
|
||||
sessionUnknownClient: '(app esborrada)',
|
||||
revokeSession: 'Revocar',
|
||||
revokeSessionConfirm: 'Vols revocar la sessió de "{name}"? Aquesta app deixarà de tenir accés.',
|
||||
revokeAllSessions: 'Tancar totes',
|
||||
revokeAllSessionsConfirm: 'Vols tancar totes les sessions actives de "{name}"? Tots els seus tokens OAuth quedaran revocats.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analítiques',
|
||||
@@ -176,6 +242,7 @@ const ca: MessageSchema = {
|
||||
last7: 'Últims 7 dies',
|
||||
last30: 'Últims 30 dies',
|
||||
last90: 'Últims 90 dies',
|
||||
exportPdf: 'Exportar PDF',
|
||||
totalUsers: 'Usuaris totals',
|
||||
activeSessions: 'Sessions actives',
|
||||
activeSessionsHint: 'tokens OAuth vigents',
|
||||
@@ -203,6 +270,86 @@ const ca: MessageSchema = {
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Tauler admin:',
|
||||
barAriaLabel: "{date}: {oauth} logins d'apps, {session} al tauler admin",
|
||||
loginFailureRate: 'Taxa de fallades de login',
|
||||
loginFailureRateHint: '{count} fallits en els últims 7 dies',
|
||||
oauthApprovalRate: "Taxa d'aprovació OAuth",
|
||||
oauthApprovalRateHint: '{approved}/{attempted} en aquest període',
|
||||
funnelTitle: "Funnel d'autorització OAuth",
|
||||
funnelEmpty: "Encara no hi ha intents d'autorització en aquest període.",
|
||||
colClient: 'Client',
|
||||
colAttempted: 'Intents',
|
||||
colApproved: 'Aprovats',
|
||||
colDenied: 'Denegats',
|
||||
colApprovalRate: 'Aprovació',
|
||||
activeTokensByClient: 'Tokens actius per client',
|
||||
noActiveTokens: 'No hi ha tokens actius ara mateix.',
|
||||
scopeUsageTitle: "Ús d'scopes",
|
||||
noScopeUsage: "Encara no s'ha concedit cap scope en aquest període.",
|
||||
},
|
||||
auditLog: {
|
||||
title: 'Audit log',
|
||||
subtitle: "Canvis de permisos, altes/baixes d'usuaris i clients OAuth.",
|
||||
allSubjects: 'Tots els subjectes',
|
||||
subjectUser: 'Usuaris',
|
||||
subjectClient: 'Clients OAuth',
|
||||
allActions: 'Totes les accions',
|
||||
empty: 'Encara no hi ha esdeveniments registrats.',
|
||||
colWhen: 'Quan',
|
||||
colActor: 'Qui',
|
||||
colAction: 'Acció',
|
||||
colSubject: 'Subjecte',
|
||||
colDetails: 'Detalls',
|
||||
colIp: 'IP',
|
||||
systemActor: '(sistema)',
|
||||
fieldChanged: '{field} canviat',
|
||||
prevPage: 'Anterior',
|
||||
nextPage: 'Següent',
|
||||
pageInfo: 'Pàgina {page} de {lastPage} — {total} esdeveniments',
|
||||
actions: {
|
||||
userCreated: 'Usuari creat',
|
||||
userUpdated: 'Usuari editat',
|
||||
userDeleted: 'Usuari esborrat',
|
||||
userRestored: 'Usuari restaurat',
|
||||
userForceDeleted: 'Usuari esborrat definitivament',
|
||||
userApproved: 'Usuari aprovat',
|
||||
appRoleAssigned: 'Rol per app assignat',
|
||||
appRoleRevoked: 'Rol per app revocat',
|
||||
clientCreated: 'Client OAuth creat',
|
||||
clientRevoked: 'Client OAuth revocat',
|
||||
twoFactorEnabled: '2FA activat',
|
||||
twoFactorDisabled: '2FA desactivat',
|
||||
twoFactorRecoveryRegenerated: 'Codis de recuperació regenerats',
|
||||
sessionRevoked: 'Sessió revocada',
|
||||
sessionsRevokedAll: 'Totes les sessions tancades',
|
||||
webhookCreated: 'Webhook creat',
|
||||
webhookDeleted: 'Webhook esborrat',
|
||||
secretRotated: 'Secret rotat',
|
||||
passwordReset: 'Contrasenya restablerta',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
title: 'Seguretat',
|
||||
subtitle: 'Verificació en dos passos (2FA) per al teu compte.',
|
||||
enabled: '2FA activat',
|
||||
enabledDesc: "El teu compte demana un codi de la teva app autenticadora cada cop que inicies sessió.",
|
||||
disabled: '2FA desactivat',
|
||||
disabledDesc: 'Afegeix una capa extra de seguretat: un codi del teu telèfon a més de la contrasenya.',
|
||||
enable: 'Activar 2FA',
|
||||
disable: 'Desactivar',
|
||||
disableConfirm: 'Confirmar desactivació',
|
||||
regenerateCodes: 'Regenerar codis de recuperació',
|
||||
confirmPassword: 'Confirma la teva contrasenya',
|
||||
confirm: 'Confirmar',
|
||||
setupTitle: 'Escaneja el codi QR',
|
||||
setupDesc: 'Fes servir Google Authenticator, Authy o qualsevol app compatible amb TOTP.',
|
||||
manualEntry: 'No pots escanejar? Introdueix aquest codi a mà:',
|
||||
enterCode: 'Codi de 6 dígits',
|
||||
invalidCode: 'Codi no vàlid. Torna-ho a provar.',
|
||||
invalidPassword: 'Contrasenya incorrecta.',
|
||||
setupFailed: "No s'ha pogut iniciar l'activació del 2FA.",
|
||||
qrAlt: 'Codi QR per configurar el 2FA',
|
||||
recoveryCodesTitle: 'Guarda aquests codis de recuperació',
|
||||
recoveryCodesWarning: "Cadascun serveix una sola vegada. Fes-los servir si perds l'accés a la teva app autenticadora. No es tornaran a mostrar.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ const en: MessageSchema = {
|
||||
clients: 'OAuth Clients',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Authorized Apps',
|
||||
security: 'Security',
|
||||
users: 'Users',
|
||||
analytics: 'Analytics',
|
||||
auditLog: 'Audit log',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Sign out',
|
||||
},
|
||||
@@ -32,6 +34,34 @@ const en: MessageSchema = {
|
||||
noAccount: "Don't have an account?",
|
||||
signUp: 'Sign up',
|
||||
invalidCredentials: 'Invalid credentials.',
|
||||
twoFactorTitle: 'Two-factor verification',
|
||||
twoFactorSubtitle: 'Enter the code from your authenticator app (or a recovery code).',
|
||||
twoFactorCode: 'Code',
|
||||
twoFactorSubmit: 'Verify',
|
||||
invalidTwoFactorCode: 'Invalid code.',
|
||||
backToLogin: 'Back to sign in',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
},
|
||||
forgotPassword: {
|
||||
title: 'Reset password',
|
||||
subtitle: "Enter your email and we'll send you a link to choose a new password.",
|
||||
email: 'Email',
|
||||
submit: 'Send link',
|
||||
submitting: 'Sending…',
|
||||
sent: "If an account exists with that email, we've sent a link to reset the password. Check your inbox.",
|
||||
backToLogin: 'Back to sign in',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Choose a new password',
|
||||
subtitle: "Almost done — confirm the new password for your account.",
|
||||
password: 'New password',
|
||||
passwordConfirm: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
success: 'Your password has been reset. You can sign in with the new one now — we closed your active sessions for security.',
|
||||
goToLogin: 'Go to sign in',
|
||||
invalidLink: 'This link is invalid or has expired. Request a new one.',
|
||||
missingParams: 'This link is missing information. Request a new one from "Forgot your password?".',
|
||||
},
|
||||
register: {
|
||||
title: 'Create account',
|
||||
@@ -58,6 +88,8 @@ const en: MessageSchema = {
|
||||
tokensDesc: 'Generate tokens for direct API access, like API keys.',
|
||||
appsTitle: 'Authorized Apps',
|
||||
appsDesc: "Third-party apps you've granted access to your account.",
|
||||
securityTitle: 'Security',
|
||||
securityDesc: 'Enable two-factor authentication (2FA) for your account.',
|
||||
usersTitle: 'Users',
|
||||
usersDesc: 'Create, edit role/status and delete accounts (admins only).',
|
||||
analyticsTitle: 'Analytics',
|
||||
@@ -85,6 +117,32 @@ const en: MessageSchema = {
|
||||
revokeConfirm: 'Revoke client "{name}"? Its tokens will stop working.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
webhooksTitle: 'Webhooks',
|
||||
hideWebhooks: 'Hide webhooks',
|
||||
webhooksDesc: 'Outgoing notifications to your app when something relevant happens — no polling needed.',
|
||||
webhookSecretWarning: 'Save this secret now, it cannot be shown again. Use it to verify the HMAC-SHA256 signature in the X-Webhook-Signature header.',
|
||||
webhookSecretLabel: 'secret:',
|
||||
webhookPaused: 'paused',
|
||||
lastDelivery: 'last delivery',
|
||||
pending: 'pending',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
viewHistory: 'View history',
|
||||
hideHistory: 'Hide history',
|
||||
noDeliveries: 'No deliveries for this webhook yet.',
|
||||
attempts: 'attempts: {count}',
|
||||
retryDelivery: 'Retry',
|
||||
retrying: 'Retrying…',
|
||||
noWebhooks: "You haven't registered any webhooks for this app yet.",
|
||||
webhookUrl: 'Webhook URL',
|
||||
addWebhook: 'Add webhook',
|
||||
webhookDeleteConfirm: 'Delete the webhook "{url}"?',
|
||||
eventTokenRevoked: 'Token revoked',
|
||||
eventUserDeactivated: 'User deactivated',
|
||||
rotateSecret: 'Rotate secret',
|
||||
rotateSecretConfirm: 'Rotate the secret for "{name}"? The current secret will keep working for 48 more hours so integrations that haven\'t updated yet don\'t break.',
|
||||
rotateSecretFailed: 'Could not rotate the secret.',
|
||||
previousSecretStillValid: 'The previous secret stays valid until {date}.',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
@@ -169,6 +227,14 @@ const en: MessageSchema = {
|
||||
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.',
|
||||
sessionsTitle: 'Active sessions',
|
||||
sessionsDesc: 'Active OAuth tokens for this user — apps with access to their account right now.',
|
||||
noSessions: 'No active sessions.',
|
||||
sessionUnknownClient: '(deleted app)',
|
||||
revokeSession: 'Revoke',
|
||||
revokeSessionConfirm: 'Revoke the session for "{name}"? That app will lose access.',
|
||||
revokeAllSessions: 'Revoke all',
|
||||
revokeAllSessionsConfirm: 'Revoke all active sessions for "{name}"? All their OAuth tokens will be revoked.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analytics',
|
||||
@@ -176,6 +242,7 @@ const en: MessageSchema = {
|
||||
last7: 'Last 7 days',
|
||||
last30: 'Last 30 days',
|
||||
last90: 'Last 90 days',
|
||||
exportPdf: 'Export PDF',
|
||||
totalUsers: 'Total users',
|
||||
activeSessions: 'Active sessions',
|
||||
activeSessionsHint: 'valid OAuth tokens',
|
||||
@@ -203,6 +270,86 @@ const en: MessageSchema = {
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Admin panel:',
|
||||
barAriaLabel: '{date}: {oauth} app logins, {session} to admin panel',
|
||||
loginFailureRate: 'Login failure rate',
|
||||
loginFailureRateHint: '{count} failed in the last 7 days',
|
||||
oauthApprovalRate: 'OAuth approval rate',
|
||||
oauthApprovalRateHint: '{approved}/{attempted} in this period',
|
||||
funnelTitle: 'OAuth authorization funnel',
|
||||
funnelEmpty: 'No authorization attempts in this period yet.',
|
||||
colClient: 'Client',
|
||||
colAttempted: 'Attempts',
|
||||
colApproved: 'Approved',
|
||||
colDenied: 'Denied',
|
||||
colApprovalRate: 'Approval',
|
||||
activeTokensByClient: 'Active tokens by client',
|
||||
noActiveTokens: 'No active tokens right now.',
|
||||
scopeUsageTitle: 'Scope usage',
|
||||
noScopeUsage: 'No scopes granted in this period yet.',
|
||||
},
|
||||
auditLog: {
|
||||
title: 'Audit log',
|
||||
subtitle: 'Permission changes, user and OAuth client create/delete events.',
|
||||
allSubjects: 'All subjects',
|
||||
subjectUser: 'Users',
|
||||
subjectClient: 'OAuth clients',
|
||||
allActions: 'All actions',
|
||||
empty: 'No events recorded yet.',
|
||||
colWhen: 'When',
|
||||
colActor: 'Who',
|
||||
colAction: 'Action',
|
||||
colSubject: 'Subject',
|
||||
colDetails: 'Details',
|
||||
colIp: 'IP',
|
||||
systemActor: '(system)',
|
||||
fieldChanged: '{field} changed',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
pageInfo: 'Page {page} of {lastPage} — {total} events',
|
||||
actions: {
|
||||
userCreated: 'User created',
|
||||
userUpdated: 'User updated',
|
||||
userDeleted: 'User deleted',
|
||||
userRestored: 'User restored',
|
||||
userForceDeleted: 'User permanently deleted',
|
||||
userApproved: 'User approved',
|
||||
appRoleAssigned: 'App role assigned',
|
||||
appRoleRevoked: 'App role revoked',
|
||||
clientCreated: 'OAuth client created',
|
||||
clientRevoked: 'OAuth client revoked',
|
||||
twoFactorEnabled: '2FA enabled',
|
||||
twoFactorDisabled: '2FA disabled',
|
||||
twoFactorRecoveryRegenerated: 'Recovery codes regenerated',
|
||||
sessionRevoked: 'Session revoked',
|
||||
sessionsRevokedAll: 'All sessions revoked',
|
||||
webhookCreated: 'Webhook created',
|
||||
webhookDeleted: 'Webhook deleted',
|
||||
secretRotated: 'Secret rotated',
|
||||
passwordReset: 'Password reset',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
subtitle: 'Two-factor authentication (2FA) for your account.',
|
||||
enabled: '2FA enabled',
|
||||
enabledDesc: 'Your account asks for a code from your authenticator app every time you sign in.',
|
||||
disabled: '2FA disabled',
|
||||
disabledDesc: 'Add an extra layer of security: a code from your phone in addition to your password.',
|
||||
enable: 'Enable 2FA',
|
||||
disable: 'Disable',
|
||||
disableConfirm: 'Confirm disable',
|
||||
regenerateCodes: 'Regenerate recovery codes',
|
||||
confirmPassword: 'Confirm your password',
|
||||
confirm: 'Confirm',
|
||||
setupTitle: 'Scan the QR code',
|
||||
setupDesc: 'Use Google Authenticator, Authy, or any TOTP-compatible app.',
|
||||
manualEntry: "Can't scan? Enter this code manually:",
|
||||
enterCode: '6-digit code',
|
||||
invalidCode: 'Invalid code. Try again.',
|
||||
invalidPassword: 'Incorrect password.',
|
||||
setupFailed: 'Could not start 2FA setup.',
|
||||
qrAlt: 'QR code to set up 2FA',
|
||||
recoveryCodesTitle: 'Save these recovery codes',
|
||||
recoveryCodesWarning: "Each one works once. Use them if you lose access to your authenticator app. They won't be shown again.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ const es = {
|
||||
clients: 'Clientes OAuth',
|
||||
tokens: 'Personal Access Tokens',
|
||||
authorizedApps: 'Apps autorizadas',
|
||||
security: 'Seguridad',
|
||||
users: 'Usuarios',
|
||||
analytics: 'Analíticas',
|
||||
auditLog: 'Audit log',
|
||||
apiDocs: 'API Docs',
|
||||
logout: 'Salir',
|
||||
},
|
||||
@@ -30,6 +32,34 @@ const es = {
|
||||
noAccount: '¿No tenés cuenta?',
|
||||
signUp: 'Registrate',
|
||||
invalidCredentials: 'Credenciales inválidas.',
|
||||
twoFactorTitle: 'Verificación en dos pasos',
|
||||
twoFactorSubtitle: 'Ingresá el código de tu app autenticadora (o un código de recuperación).',
|
||||
twoFactorCode: 'Código',
|
||||
twoFactorSubmit: 'Verificar',
|
||||
invalidTwoFactorCode: 'Código inválido.',
|
||||
backToLogin: 'Volver a ingresar',
|
||||
forgotPassword: '¿Olvidaste tu contraseña?',
|
||||
},
|
||||
forgotPassword: {
|
||||
title: 'Restablecer contraseña',
|
||||
subtitle: 'Ingresá tu email y te mandamos un enlace para elegir una contraseña nueva.',
|
||||
email: 'Email',
|
||||
submit: 'Enviar enlace',
|
||||
submitting: 'Enviando…',
|
||||
sent: 'Si existe una cuenta con ese email, te enviamos un enlace para restablecer la contraseña. Revisá tu bandeja de entrada.',
|
||||
backToLogin: 'Volver a ingresar',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Elegí una contraseña nueva',
|
||||
subtitle: 'Ya casi — confirmá la contraseña nueva para tu cuenta.',
|
||||
password: 'Contraseña nueva',
|
||||
passwordConfirm: 'Confirmar contraseña',
|
||||
submit: 'Restablecer contraseña',
|
||||
submitting: 'Restableciendo…',
|
||||
success: 'Tu contraseña se restableció. Ya podés ingresar con la nueva — cerramos las sesiones activas por seguridad.',
|
||||
goToLogin: 'Ir a ingresar',
|
||||
invalidLink: 'Este enlace es inválido o venció. Pedí uno nuevo.',
|
||||
missingParams: 'Falta información en el enlace. Pedí uno nuevo desde "¿Olvidaste tu contraseña?".',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear cuenta',
|
||||
@@ -56,6 +86,8 @@ const es = {
|
||||
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.',
|
||||
securityTitle: 'Seguridad',
|
||||
securityDesc: 'Activá verificación en dos pasos (2FA) para tu cuenta.',
|
||||
usersTitle: 'Usuarios',
|
||||
usersDesc: 'Alta, edición de rol/estado y baja de cuentas (solo admins).',
|
||||
analyticsTitle: 'Analíticas',
|
||||
@@ -83,6 +115,32 @@ const es = {
|
||||
revokeConfirm: '¿Revocar el cliente "{name}"? Sus tokens dejarán de funcionar.',
|
||||
clientIdShort: 'client_id',
|
||||
redirectLabel: 'redirect',
|
||||
webhooksTitle: 'Webhooks',
|
||||
hideWebhooks: 'Ocultar webhooks',
|
||||
webhooksDesc: 'Notificaciones salientes a tu app cuando pasa algo relevante — sin tener que hacer polling.',
|
||||
webhookSecretWarning: 'Guardá este secret ahora, no se puede volver a ver. Usalo para verificar la firma HMAC-SHA256 en el header X-Webhook-Signature.',
|
||||
webhookSecretLabel: 'secret:',
|
||||
webhookPaused: 'pausado',
|
||||
lastDelivery: 'último envío',
|
||||
pending: 'pendiente',
|
||||
pause: 'Pausar',
|
||||
resume: 'Reanudar',
|
||||
viewHistory: 'Ver historial',
|
||||
hideHistory: 'Ocultar historial',
|
||||
noDeliveries: 'Todavía no hubo entregas para este webhook.',
|
||||
attempts: 'intentos: {count}',
|
||||
retryDelivery: 'Reintentar',
|
||||
retrying: 'Reintentando…',
|
||||
noWebhooks: 'Todavía no registraste ningún webhook para esta app.',
|
||||
webhookUrl: 'URL del webhook',
|
||||
addWebhook: 'Agregar webhook',
|
||||
webhookDeleteConfirm: '¿Borrar el webhook "{url}"?',
|
||||
eventTokenRevoked: 'Token revocado',
|
||||
eventUserDeactivated: 'Usuario desactivado',
|
||||
rotateSecret: 'Rotar secret',
|
||||
rotateSecretConfirm: '¿Rotar el secret de "{name}"? El secret actual va a seguir funcionando 48 horas más para no cortar integraciones que todavía no se actualizaron.',
|
||||
rotateSecretFailed: 'No se pudo rotar el secret.',
|
||||
previousSecretStillValid: 'El secret anterior sigue siendo válido hasta el {date}.',
|
||||
},
|
||||
tokens: {
|
||||
title: 'Personal Access Tokens',
|
||||
@@ -167,6 +225,14 @@ const es = {
|
||||
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.',
|
||||
sessionsTitle: 'Sesiones activas',
|
||||
sessionsDesc: 'Tokens OAuth vigentes de este usuario — apps con acceso a su cuenta ahora mismo.',
|
||||
noSessions: 'No hay sesiones activas.',
|
||||
sessionUnknownClient: '(app borrada)',
|
||||
revokeSession: 'Revocar',
|
||||
revokeSessionConfirm: '¿Revocar la sesión de "{name}"? Esa app va a dejar de tener acceso.',
|
||||
revokeAllSessions: 'Cerrar todas',
|
||||
revokeAllSessionsConfirm: '¿Cerrar todas las sesiones activas de "{name}"? Todos sus tokens OAuth van a quedar revocados.',
|
||||
},
|
||||
analytics: {
|
||||
title: 'Analíticas',
|
||||
@@ -174,6 +240,7 @@ const es = {
|
||||
last7: 'Últimos 7 días',
|
||||
last30: 'Últimos 30 días',
|
||||
last90: 'Últimos 90 días',
|
||||
exportPdf: 'Exportar PDF',
|
||||
totalUsers: 'Usuarios totales',
|
||||
activeSessions: 'Sesiones activas',
|
||||
activeSessionsHint: 'tokens OAuth vigentes',
|
||||
@@ -201,6 +268,86 @@ const es = {
|
||||
tooltipAppLabel: 'Apps (OAuth):',
|
||||
tooltipSessionLabel: 'Panel admin:',
|
||||
barAriaLabel: '{date}: {oauth} logins de apps, {session} al panel admin',
|
||||
loginFailureRate: 'Tasa de fallos de login',
|
||||
loginFailureRateHint: '{count} fallidos en 7 días',
|
||||
oauthApprovalRate: 'Tasa de aprobación OAuth',
|
||||
oauthApprovalRateHint: '{approved}/{attempted} en el período',
|
||||
funnelTitle: 'Funnel de autorización OAuth',
|
||||
funnelEmpty: 'Todavía no hay intentos de autorización en este período.',
|
||||
colClient: 'Cliente',
|
||||
colAttempted: 'Intentos',
|
||||
colApproved: 'Aprobados',
|
||||
colDenied: 'Denegados',
|
||||
colApprovalRate: 'Aprobación',
|
||||
activeTokensByClient: 'Tokens activos por cliente',
|
||||
noActiveTokens: 'No hay tokens activos por ahora.',
|
||||
scopeUsageTitle: 'Uso de scopes',
|
||||
noScopeUsage: 'Todavía no se otorgó ningún scope en este período.',
|
||||
},
|
||||
auditLog: {
|
||||
title: 'Audit log',
|
||||
subtitle: 'Cambios de permisos, altas/bajas de usuarios y clientes OAuth.',
|
||||
allSubjects: 'Todos los sujetos',
|
||||
subjectUser: 'Usuarios',
|
||||
subjectClient: 'Clientes OAuth',
|
||||
allActions: 'Todas las acciones',
|
||||
empty: 'Todavía no hay eventos registrados.',
|
||||
colWhen: 'Cuándo',
|
||||
colActor: 'Quién',
|
||||
colAction: 'Acción',
|
||||
colSubject: 'Sujeto',
|
||||
colDetails: 'Detalles',
|
||||
colIp: 'IP',
|
||||
systemActor: '(sistema)',
|
||||
fieldChanged: '{field} cambiada',
|
||||
prevPage: 'Anterior',
|
||||
nextPage: 'Siguiente',
|
||||
pageInfo: 'Página {page} de {lastPage} — {total} eventos',
|
||||
actions: {
|
||||
userCreated: 'Usuario creado',
|
||||
userUpdated: 'Usuario editado',
|
||||
userDeleted: 'Usuario borrado',
|
||||
userRestored: 'Usuario restaurado',
|
||||
userForceDeleted: 'Usuario borrado definitivamente',
|
||||
userApproved: 'Usuario aprobado',
|
||||
appRoleAssigned: 'Rol por app asignado',
|
||||
appRoleRevoked: 'Rol por app revocado',
|
||||
clientCreated: 'Cliente OAuth creado',
|
||||
clientRevoked: 'Cliente OAuth revocado',
|
||||
twoFactorEnabled: '2FA activado',
|
||||
twoFactorDisabled: '2FA desactivado',
|
||||
twoFactorRecoveryRegenerated: 'Códigos de recuperación regenerados',
|
||||
sessionRevoked: 'Sesión revocada',
|
||||
sessionsRevokedAll: 'Todas las sesiones cerradas',
|
||||
webhookCreated: 'Webhook creado',
|
||||
webhookDeleted: 'Webhook borrado',
|
||||
secretRotated: 'Secret rotado',
|
||||
passwordReset: 'Contraseña restablecida',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
title: 'Seguridad',
|
||||
subtitle: 'Verificación en dos pasos (2FA) para tu cuenta.',
|
||||
enabled: '2FA activo',
|
||||
enabledDesc: 'Tu cuenta pide un código de tu app autenticadora cada vez que iniciás sesión.',
|
||||
disabled: '2FA desactivado',
|
||||
disabledDesc: 'Agregá una capa extra de seguridad: un código de tu teléfono además de la contraseña.',
|
||||
enable: 'Activar 2FA',
|
||||
disable: 'Desactivar',
|
||||
disableConfirm: 'Confirmar desactivación',
|
||||
regenerateCodes: 'Regenerar códigos de recuperación',
|
||||
confirmPassword: 'Confirmá tu contraseña',
|
||||
confirm: 'Confirmar',
|
||||
setupTitle: 'Escaneá el código QR',
|
||||
setupDesc: 'Usá Google Authenticator, Authy o cualquier app compatible con TOTP.',
|
||||
manualEntry: '¿No podés escanear? Ingresá este código a mano:',
|
||||
enterCode: 'Código de 6 dígitos',
|
||||
invalidCode: 'Código inválido. Probá de nuevo.',
|
||||
invalidPassword: 'Contraseña incorrecta.',
|
||||
setupFailed: 'No se pudo iniciar la activación de 2FA.',
|
||||
qrAlt: 'Código QR para configurar 2FA',
|
||||
recoveryCodesTitle: 'Guardá estos códigos de recuperación',
|
||||
recoveryCodesWarning: 'Cada uno sirve una sola vez. Usalos si perdés acceso a tu app autenticadora. No se van a volver a mostrar.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios';
|
||||
import AppLogo from '../components/AppLogo.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const data = window.__OAUTH_AUTHORIZE__ ?? null;
|
||||
const approveForm = ref<HTMLFormElement | null>(null);
|
||||
const denyForm = ref<HTMLFormElement | null>(null);
|
||||
|
||||
function logAuthorizationEvent(outcome: 'attempted' | 'approved' | 'denied'): Promise<void> {
|
||||
if (!data) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return axios
|
||||
.post('/web-api/oauth/authorize-events', {
|
||||
client_id: data.client.id,
|
||||
outcome,
|
||||
scopes: data.scopes.map((scope) => scope.id),
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// Analíticas best-effort: nunca debe bloquear el flujo real de OAuth.
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
logAuthorizationEvent('attempted');
|
||||
});
|
||||
|
||||
async function submitWithOutcome(outcome: 'approved' | 'denied', form: HTMLFormElement | null): Promise<void> {
|
||||
await logAuthorizationEvent(outcome);
|
||||
form?.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -15,7 +43,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
|
||||
|
||||
<div class="auth-card auth-card--wide">
|
||||
<template v-if="data">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<AppLogo class="auth-card__logo" />
|
||||
<h1 class="auth-card__title">{{ t('authorize.title') }}</h1>
|
||||
<p class="auth-card__subtitle">
|
||||
<i18n-t keypath="authorize.wantsAccess" tag="span">
|
||||
@@ -35,7 +63,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
|
||||
</ul>
|
||||
|
||||
<div class="auth-card__actions">
|
||||
<form ref="approveForm" method="POST" action="/oauth/authorize">
|
||||
<form ref="approveForm" method="POST" action="/oauth/authorize" @submit.prevent="submitWithOutcome('approved', approveForm)">
|
||||
<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">
|
||||
@@ -43,7 +71,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form ref="denyForm" method="POST" action="/oauth/authorize">
|
||||
<form ref="denyForm" method="POST" action="/oauth/authorize" @submit.prevent="submitWithOutcome('denied', denyForm)">
|
||||
<input type="hidden" name="_token" :value="data.csrfToken" />
|
||||
<input type="hidden" name="_method" value="DELETE" />
|
||||
<input type="hidden" name="auth_token" :value="data.authToken" />
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { OAuthClient } from '../types/models';
|
||||
import type { OAuthClient, Paginated, WebhookDelivery, WebhookEndpoint } from '../types/models';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = 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 newSecret = ref<{ name: string; secret: string; id: string; previousSecretExpiresAt?: string } | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -19,6 +19,22 @@ const form = reactive({
|
||||
});
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
const WEBHOOK_EVENTS = ['token.revoked', 'user.deactivated'];
|
||||
|
||||
const expandedClientId = ref<string | null>(null);
|
||||
const webhooks = ref<WebhookEndpoint[]>([]);
|
||||
const webhookForm = reactive({ url: '', events: [] as string[] });
|
||||
const webhookErrors = ref<Record<string, string[]>>({});
|
||||
const webhookCreating = ref(false);
|
||||
const newWebhookSecret = ref<{ url: string; secret: string } | null>(null);
|
||||
|
||||
const historyWebhookId = ref<number | null>(null);
|
||||
const deliveries = ref<WebhookDelivery[]>([]);
|
||||
const deliveriesLoading = ref(false);
|
||||
const deliveriesPage = ref(1);
|
||||
const deliveriesLastPage = ref(1);
|
||||
const retryingDeliveryId = ref<number | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<OAuthClient[]>('/web-api/clients');
|
||||
@@ -64,6 +80,28 @@ async function revoke(client: OAuthClient): Promise<void> {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function rotateSecret(client: OAuthClient): Promise<void> {
|
||||
if (!confirm(t('clients.rotateSecretConfirm', { name: client.name }))) return;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ plain_secret: string; previous_secret_expires_at: string }>(
|
||||
`/web-api/clients/${client.id}/rotate-secret`,
|
||||
);
|
||||
newSecret.value = {
|
||||
name: client.name,
|
||||
secret: data.plain_secret,
|
||||
id: client.id,
|
||||
previousSecretExpiresAt: data.previous_secret_expires_at,
|
||||
};
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('clients.rotateSecretFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function grantLabel(grantTypes: string[]): string {
|
||||
if (grantTypes.includes('authorization_code')) return t('clients.grantAuthCode');
|
||||
if (grantTypes.includes('client_credentials')) return t('clients.grantClientCredentials');
|
||||
@@ -71,6 +109,108 @@ function grantLabel(grantTypes: string[]): string {
|
||||
return grantTypes.join(', ');
|
||||
}
|
||||
|
||||
async function toggleWebhooks(client: OAuthClient): Promise<void> {
|
||||
if (expandedClientId.value === client.id) {
|
||||
expandedClientId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
expandedClientId.value = client.id;
|
||||
webhookForm.url = '';
|
||||
webhookForm.events = [];
|
||||
webhookErrors.value = {};
|
||||
newWebhookSecret.value = null;
|
||||
historyWebhookId.value = null;
|
||||
await loadWebhooks(client);
|
||||
}
|
||||
|
||||
async function loadWebhooks(client: OAuthClient): Promise<void> {
|
||||
const { data } = await axios.get<WebhookEndpoint[]>(`/web-api/clients/${client.id}/webhooks`);
|
||||
webhooks.value = data;
|
||||
}
|
||||
|
||||
async function createWebhook(client: OAuthClient): Promise<void> {
|
||||
webhookCreating.value = true;
|
||||
webhookErrors.value = {};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<WebhookEndpoint & { secret: string }>(`/web-api/clients/${client.id}/webhooks`, webhookForm);
|
||||
newWebhookSecret.value = { url: data.url, secret: data.secret };
|
||||
webhookForm.url = '';
|
||||
webhookForm.events = [];
|
||||
await loadWebhooks(client);
|
||||
} catch (e: any) {
|
||||
webhookErrors.value = e.response?.data?.errors ?? {};
|
||||
} finally {
|
||||
webhookCreating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebhookActive(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
|
||||
await axios.put(`/web-api/clients/${client.id}/webhooks/${webhook.id}`, { active: !webhook.active });
|
||||
await loadWebhooks(client);
|
||||
}
|
||||
|
||||
async function deleteWebhook(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
|
||||
if (!confirm(t('clients.webhookDeleteConfirm', { url: webhook.url }))) return;
|
||||
|
||||
await axios.delete(`/web-api/clients/${client.id}/webhooks/${webhook.id}`);
|
||||
await loadWebhooks(client);
|
||||
}
|
||||
|
||||
async function toggleHistory(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
|
||||
if (historyWebhookId.value === webhook.id) {
|
||||
historyWebhookId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
historyWebhookId.value = webhook.id;
|
||||
deliveriesPage.value = 1;
|
||||
await loadDeliveries(client, webhook);
|
||||
}
|
||||
|
||||
async function loadDeliveries(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
|
||||
deliveriesLoading.value = true;
|
||||
const { data } = await axios.get<Paginated<WebhookDelivery>>(
|
||||
`/web-api/clients/${client.id}/webhooks/${webhook.id}/deliveries`,
|
||||
{ params: { page: deliveriesPage.value } },
|
||||
);
|
||||
deliveries.value = data.data;
|
||||
deliveriesLastPage.value = data.last_page;
|
||||
deliveriesLoading.value = false;
|
||||
}
|
||||
|
||||
function goToDeliveriesPage(client: OAuthClient, webhook: WebhookEndpoint, next: number): void {
|
||||
if (next < 1 || next > deliveriesLastPage.value) return;
|
||||
deliveriesPage.value = next;
|
||||
loadDeliveries(client, webhook);
|
||||
}
|
||||
|
||||
async function retryDelivery(client: OAuthClient, webhook: WebhookEndpoint, delivery: WebhookDelivery): Promise<void> {
|
||||
retryingDeliveryId.value = delivery.id;
|
||||
try {
|
||||
await axios.post(`/web-api/clients/${client.id}/webhooks/${webhook.id}/deliveries/${delivery.id}/retry`);
|
||||
await loadDeliveries(client, webhook);
|
||||
} finally {
|
||||
retryingDeliveryId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryStatusClass(delivery: WebhookDelivery): string {
|
||||
if (delivery.delivered_at) return 'badge--active';
|
||||
if (delivery.response_status) return 'badge--danger';
|
||||
return 'badge--inactive';
|
||||
}
|
||||
|
||||
function deliveryStatusLabel(delivery: WebhookDelivery): string {
|
||||
if (delivery.response_status) return String(delivery.response_status);
|
||||
return t('clients.pending');
|
||||
}
|
||||
|
||||
function eventLabel(event: string): string {
|
||||
return event === 'token.revoked' ? t('clients.eventTokenRevoked') : t('clients.eventUserDeactivated');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -83,6 +223,9 @@ onMounted(load);
|
||||
<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>
|
||||
<p v-if="newSecret.previousSecretExpiresAt" class="banner__line">
|
||||
{{ t('clients.previousSecretStillValid', { date: formatDate(newSecret.previousSecretExpiresAt) }) }}
|
||||
</p>
|
||||
<button class="banner__close" @click="newSecret = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -126,17 +269,119 @@ onMounted(load);
|
||||
<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>
|
||||
<template v-for="client in clients" :key="client.id">
|
||||
<div 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>
|
||||
<div class="clients__item-actions">
|
||||
<button @click="toggleWebhooks(client)" class="link">
|
||||
{{ expandedClientId === client.id ? t('clients.hideWebhooks') : t('clients.webhooksTitle') }}
|
||||
</button>
|
||||
<button @click="rotateSecret(client)" class="link">{{ t('clients.rotateSecret') }}</button>
|
||||
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedClientId === client.id" class="card clients__webhooks">
|
||||
<h3 class="card__title">{{ t('clients.webhooksTitle') }}</h3>
|
||||
<p class="clients__item-meta">{{ t('clients.webhooksDesc') }}</p>
|
||||
|
||||
<div v-if="newWebhookSecret" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('clients.webhookSecretWarning') }}</p>
|
||||
<p class="banner__line"><strong>{{ t('clients.webhookSecretLabel') }}</strong> <code class="banner__code">{{ newWebhookSecret.secret }}</code></p>
|
||||
<button class="banner__close" @click="newWebhookSecret = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="webhooks.length" class="clients__webhook-list">
|
||||
<template v-for="webhook in webhooks" :key="webhook.id">
|
||||
<li class="clients__webhook-row">
|
||||
<div>
|
||||
<p class="clients__webhook-url">{{ webhook.url }}</p>
|
||||
<p class="clients__item-meta">
|
||||
{{ webhook.events.map(eventLabel).join(', ') }}
|
||||
<span v-if="!webhook.active"> · {{ t('clients.webhookPaused') }}</span>
|
||||
<span v-if="webhook.last_delivery">
|
||||
· {{ t('clients.lastDelivery') }}: {{ webhook.last_delivery.response_status ?? t('clients.pending') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="clients__webhook-actions">
|
||||
<button @click="toggleHistory(client, webhook)" class="link">
|
||||
{{ historyWebhookId === webhook.id ? t('clients.hideHistory') : t('clients.viewHistory') }}
|
||||
</button>
|
||||
<button @click="toggleWebhookActive(client, webhook)" class="link">
|
||||
{{ webhook.active ? t('clients.pause') : t('clients.resume') }}
|
||||
</button>
|
||||
<button @click="deleteWebhook(client, webhook)" class="link link--danger">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li v-if="historyWebhookId === webhook.id" class="clients__delivery-history">
|
||||
<p v-if="deliveriesLoading" class="clients__item-meta">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!deliveries.length" class="clients__item-meta">{{ t('clients.noDeliveries') }}</p>
|
||||
|
||||
<ul v-else class="clients__delivery-list">
|
||||
<li v-for="delivery in deliveries" :key="delivery.id" class="clients__delivery-row">
|
||||
<div class="clients__delivery-info">
|
||||
<span class="badge" :class="deliveryStatusClass(delivery)">
|
||||
<span class="badge__dot"></span>
|
||||
{{ deliveryStatusLabel(delivery) }}
|
||||
</span>
|
||||
<span class="clients__item-meta">
|
||||
{{ eventLabel(delivery.event) }} · {{ formatDate(delivery.created_at) }} ·
|
||||
{{ t('clients.attempts', { count: delivery.attempts }) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!delivery.delivered_at"
|
||||
@click="retryDelivery(client, webhook, delivery)"
|
||||
:disabled="retryingDeliveryId === delivery.id"
|
||||
class="link"
|
||||
>
|
||||
{{ retryingDeliveryId === delivery.id ? t('clients.retrying') : t('clients.retryDelivery') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="deliveries.length" class="clients__delivery-pager">
|
||||
<button class="link" :disabled="deliveriesPage <= 1" @click="goToDeliveriesPage(client, webhook, deliveriesPage - 1)">
|
||||
{{ t('auditLog.prevPage') }}
|
||||
</button>
|
||||
<span class="clients__item-meta">{{ deliveriesPage }} / {{ deliveriesLastPage }}</span>
|
||||
<button class="link" :disabled="deliveriesPage >= deliveriesLastPage" @click="goToDeliveriesPage(client, webhook, deliveriesPage + 1)">
|
||||
{{ t('auditLog.nextPage') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<p v-else class="clients__item-meta">{{ t('clients.noWebhooks') }}</p>
|
||||
|
||||
<form class="clients__webhook-form" @submit.prevent="createWebhook(client)">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('clients.webhookUrl') }}</label>
|
||||
<input v-model="webhookForm.url" type="url" required placeholder="https://mi-app.com/webhooks/iam" class="field__input" />
|
||||
<p v-if="webhookErrors.url" class="field__error">{{ webhookErrors.url[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field__checkbox-row" v-for="event in WEBHOOK_EVENTS" :key="event">
|
||||
<input :id="`event-${client.id}-${event}`" type="checkbox" :value="event" v-model="webhookForm.events" class="field__checkbox" />
|
||||
<label :for="`event-${client.id}-${event}`">{{ eventLabel(event) }}</label>
|
||||
</div>
|
||||
<p v-if="webhookErrors.events" class="field__error">{{ webhookErrors.events[0] }}</p>
|
||||
|
||||
<button type="submit" :disabled="webhookCreating" class="btn btn--dark">
|
||||
{{ webhookCreating ? t('common.creating') : t('clients.addWebhook') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -170,12 +415,99 @@ onMounted(load);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clients__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clients__item-meta {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.clients__webhooks {
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.clients__webhook-list {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.clients__webhook-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.clients__webhook-url {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.clients__webhook-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clients__delivery-history {
|
||||
border: 1px solid var(--color-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
margin-top: -0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.clients__delivery-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.clients__delivery-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clients__delivery-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clients__delivery-pager {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.clients__webhook-form {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.clients__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -27,6 +27,11 @@ const { t } = useI18n();
|
||||
<p class="card__subtitle">{{ t('dashboard.appsDesc') }}</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/security" class="card card--interactive">
|
||||
<h2 class="card__title">{{ t('dashboard.securityTitle') }}</h2>
|
||||
<p class="card__subtitle">{{ t('dashboard.securityDesc') }}</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>
|
||||
|
||||
63
resources/js/pages/ForgotPassword.vue
Normal file
63
resources/js/pages/ForgotPassword.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios';
|
||||
import AppLogo from '../components/AppLogo.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ email: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
const sent = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
await axios.post('/web-api/forgot-password', form);
|
||||
sent.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">
|
||||
<AppLogo class="auth-card__logo" />
|
||||
|
||||
<template v-if="!sent">
|
||||
<h1 class="auth-card__title">{{ t('forgotPassword.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('forgotPassword.subtitle') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('forgotPassword.email') }}</label>
|
||||
<input v-model="form.email" type="email" required autofocus class="field__input" />
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('forgotPassword.submitting') : t('forgotPassword.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="auth-card__title">{{ t('forgotPassword.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('forgotPassword.sent') }}</p>
|
||||
</template>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<router-link to="/login" class="link">{{ t('forgotPassword.backToLogin') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import AppLogo from '../components/AppLogo.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
@@ -10,28 +11,59 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({ email: '', password: '' });
|
||||
const code = ref('');
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
const step = ref<'credentials' | 'two_factor'>('credentials');
|
||||
|
||||
function redirectAfterLogin(intended: string | null): void {
|
||||
if (intended) {
|
||||
window.location.href = intended;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push((route.query.redirect as string) || { name: 'dashboard' });
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const intended = await auth.login(form);
|
||||
const result = await auth.login(form);
|
||||
|
||||
if (intended) {
|
||||
window.location.href = intended;
|
||||
if (result.twoFactor) {
|
||||
step.value = 'two_factor';
|
||||
return;
|
||||
}
|
||||
|
||||
router.push((route.query.redirect as string) || { name: 'dashboard' });
|
||||
redirectAfterLogin(result.intended);
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { email: [t('login.invalidCredentials')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTwoFactor(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const intended = await auth.loginTwoFactor(code.value);
|
||||
redirectAfterLogin(intended);
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { code: [t('login.invalidTwoFactorCode')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToCredentials(): void {
|
||||
step.value = 'credentials';
|
||||
code.value = '';
|
||||
errors.value = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,31 +71,56 @@ async function submit(): Promise<void> {
|
||||
<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>
|
||||
<AppLogo class="auth-card__logo" />
|
||||
|
||||
<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>
|
||||
<template v-if="step === 'credentials'">
|
||||
<h1 class="auth-card__title">{{ t('login.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('login.subtitle') }}</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.password') }}</label>
|
||||
<input v-model="form.password" type="password" required class="field__input" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('login.submitting') : t('login.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.password') }}</label>
|
||||
<input v-model="form.password" type="password" required class="field__input" />
|
||||
<router-link to="/forgot-password" class="field__hint link">{{ t('login.forgotPassword') }}</router-link>
|
||||
</div>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
{{ t('login.noAccount') }}
|
||||
<router-link to="/register" class="link">{{ t('login.signUp') }}</router-link>
|
||||
</p>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="auth-card__title">{{ t('login.twoFactorTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('login.twoFactorSubtitle') }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submitTwoFactor">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('login.twoFactorCode') }}</label>
|
||||
<input v-model="code" required autofocus autocomplete="one-time-code" class="field__input" />
|
||||
<p v-if="errors.code" class="field__error">{{ errors.code[0] }}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('login.submitting') : t('login.twoFactorSubmit') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-card__footer">
|
||||
<button type="button" @click="backToCredentials" class="link">{{ t('login.backToLogin') }}</button>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import AppLogo from '../components/AppLogo.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -49,7 +50,7 @@ async function submit(): Promise<void> {
|
||||
|
||||
<div class="auth-card">
|
||||
<template v-if="registered">
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<AppLogo class="auth-card__logo" />
|
||||
<h1 class="auth-card__title">{{ t('register.pendingTitle') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('register.pendingBody') }}</p>
|
||||
|
||||
@@ -59,7 +60,7 @@ async function submit(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="auth-card__logo">O2</span>
|
||||
<AppLogo class="auth-card__logo" />
|
||||
<h1 class="auth-card__title">{{ t('register.title') }}</h1>
|
||||
|
||||
<p v-if="isFirstUser" class="auth-card__banner">{{ t('register.firstUserBanner') }}</p>
|
||||
|
||||
81
resources/js/pages/ResetPassword.vue
Normal file
81
resources/js/pages/ResetPassword.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import axios from 'axios';
|
||||
import AppLogo from '../components/AppLogo.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const token = String(route.query.token ?? '');
|
||||
const email = String(route.query.email ?? '');
|
||||
const missingParams = !token || !email;
|
||||
|
||||
const form = reactive({ password: '', password_confirmation: '' });
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
await axios.post('/web-api/reset-password', { ...form, token, email });
|
||||
success.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">
|
||||
<AppLogo class="auth-card__logo" />
|
||||
|
||||
<template v-if="missingParams">
|
||||
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
|
||||
<p class="field__error">{{ t('resetPassword.missingParams') }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!success">
|
||||
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('resetPassword.subtitle') }}</p>
|
||||
|
||||
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('resetPassword.password') }}</label>
|
||||
<input v-model="form.password" type="password" required minlength="8" autofocus class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('resetPassword.passwordConfirm') }}</label>
|
||||
<input v-model="form.password_confirmation" type="password" required minlength="8" class="field__input" />
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
|
||||
{{ loading ? t('resetPassword.submitting') : t('resetPassword.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
|
||||
<p class="auth-card__subtitle">{{ t('resetPassword.success') }}</p>
|
||||
<button type="button" class="btn btn--primary btn--pill btn--full" @click="router.push({ name: 'login' })">
|
||||
{{ t('resetPassword.goToLogin') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
259
resources/js/pages/Security.vue
Normal file
259
resources/js/pages/Security.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import QRCode from 'qrcode';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const loading = ref(false);
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
|
||||
const settingUp = ref(false);
|
||||
const otpauthUrl = ref('');
|
||||
const secret = ref('');
|
||||
const qrDataUrl = ref('');
|
||||
const confirmCode = ref('');
|
||||
|
||||
const recoveryCodes = ref<string[] | null>(null);
|
||||
|
||||
const showDisableForm = ref(false);
|
||||
const showRegenerateForm = ref(false);
|
||||
const password = ref('');
|
||||
|
||||
async function startSetup(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ secret: string; otpauth_url: string }>('/web-api/2fa/setup');
|
||||
secret.value = data.secret;
|
||||
otpauthUrl.value = data.otpauth_url;
|
||||
qrDataUrl.value = await QRCode.toDataURL(data.otpauth_url);
|
||||
settingUp.value = true;
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data?.message ?? t('security.setupFailed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSetup(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/confirm', { code: confirmCode.value });
|
||||
recoveryCodes.value = data.recovery_codes;
|
||||
cancelSetup();
|
||||
await auth.fetchUser();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { code: [t('security.invalidCode')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSetup(): void {
|
||||
settingUp.value = false;
|
||||
secret.value = '';
|
||||
otpauthUrl.value = '';
|
||||
qrDataUrl.value = '';
|
||||
confirmCode.value = '';
|
||||
errors.value = {};
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
await axios.post('/web-api/2fa/disable', { password: password.value });
|
||||
password.value = '';
|
||||
showDisableForm.value = false;
|
||||
recoveryCodes.value = null;
|
||||
await auth.fetchUser();
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateRecoveryCodes(): Promise<void> {
|
||||
loading.value = true;
|
||||
errors.value = {};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/recovery-codes', { password: password.value });
|
||||
recoveryCodes.value = data.recovery_codes;
|
||||
password.value = '';
|
||||
showRegenerateForm.value = false;
|
||||
} catch (e: any) {
|
||||
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="security">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('security.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('security.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recoveryCodes" class="banner banner--warning">
|
||||
<p class="banner__title">{{ t('security.recoveryCodesTitle') }}</p>
|
||||
<p class="banner__line">{{ t('security.recoveryCodesWarning') }}</p>
|
||||
<ul class="security__codes">
|
||||
<li v-for="rc in recoveryCodes" :key="rc"><code class="banner__code">{{ rc }}</code></li>
|
||||
</ul>
|
||||
<button class="banner__close" @click="recoveryCodes = null">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.user?.two_factor_enabled && !settingUp" class="card security__card">
|
||||
<p class="security__status security__status--on">{{ t('security.enabled') }}</p>
|
||||
<p class="security__desc">{{ t('security.enabledDesc') }}</p>
|
||||
|
||||
<div class="security__actions">
|
||||
<button
|
||||
@click="showRegenerateForm = !showRegenerateForm; showDisableForm = false; errors = {}"
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
>
|
||||
{{ t('security.regenerateCodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="showDisableForm = !showDisableForm; showRegenerateForm = false; errors = {}"
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
>
|
||||
{{ t('security.disable') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-if="showRegenerateForm" class="security__password-form" @submit.prevent="regenerateRecoveryCodes">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('security.confirmPassword') }}</label>
|
||||
<input v-model="password" type="password" required class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
|
||||
</form>
|
||||
|
||||
<form v-if="showDisableForm" class="security__password-form" @submit.prevent="disable">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('security.confirmPassword') }}</label>
|
||||
<input v-model="password" type="password" required class="field__input" />
|
||||
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.disableConfirm') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!settingUp" class="card security__card">
|
||||
<p class="security__status security__status--off">{{ t('security.disabled') }}</p>
|
||||
<p class="security__desc">{{ t('security.disabledDesc') }}</p>
|
||||
<button @click="startSetup" :disabled="loading" type="button" class="btn btn--primary">{{ t('security.enable') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="card security__card">
|
||||
<h3 class="card__title">{{ t('security.setupTitle') }}</h3>
|
||||
<p class="security__desc">{{ t('security.setupDesc') }}</p>
|
||||
|
||||
<img :src="qrDataUrl" :alt="t('security.qrAlt')" class="security__qr" />
|
||||
|
||||
<p class="security__manual">
|
||||
{{ t('security.manualEntry') }} <code class="banner__code">{{ secret }}</code>
|
||||
</p>
|
||||
|
||||
<form class="security__confirm-form" @submit.prevent="confirmSetup">
|
||||
<div class="field">
|
||||
<label class="field__label">{{ t('security.enterCode') }}</label>
|
||||
<input v-model="confirmCode" required autofocus autocomplete="one-time-code" class="field__input" />
|
||||
<p v-if="errors.code" class="field__error">{{ errors.code[0] }}</p>
|
||||
</div>
|
||||
<div class="security__actions">
|
||||
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
|
||||
<button type="button" @click="cancelSetup" class="btn btn--secondary">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.security__card {
|
||||
margin-top: 1.5rem;
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.security__status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security__status--on {
|
||||
color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.security__status--off {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.security__desc {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.security__actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.security__password-form {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.security__qr {
|
||||
margin-top: 1rem;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.security__manual {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.security__confirm-form {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.security__codes {
|
||||
margin-top: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.375rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatTile from '../../components/charts/StatTile.vue';
|
||||
@@ -26,10 +26,28 @@ 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 })));
|
||||
const activeTokenItems = computed(() => (summary.value?.active_tokens_by_client ?? []).map((c) => ({ label: c.client_name, value: c.count })));
|
||||
const scopeItems = computed(() => (summary.value?.scope_usage ?? []).map((s) => ({ label: s.description, value: s.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' });
|
||||
}
|
||||
|
||||
async function exportPdf(): Promise<void> {
|
||||
const tableWasHidden = !showTable.value;
|
||||
if (tableWasHidden) showTable.value = true;
|
||||
await nextTick();
|
||||
|
||||
// Fuerza tema claro para la impresión (ahorra tinta, evita fondos oscuros
|
||||
// recortados) y lo restaura después de que el usuario cierre el diálogo.
|
||||
const wasDark = document.documentElement.classList.contains('dark');
|
||||
document.documentElement.classList.remove('dark');
|
||||
|
||||
window.print();
|
||||
|
||||
if (wasDark) document.documentElement.classList.add('dark');
|
||||
if (tableWasHidden) showTable.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,12 +58,18 @@ function formatDateTime(iso: string): string {
|
||||
<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 class="analytics__header-controls no-print">
|
||||
<!-- 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>
|
||||
|
||||
<button v-if="summary" @click="exportPdf" type="button" class="btn btn--secondary">
|
||||
{{ t('analytics.exportPdf') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading && !summary" class="analytics__loading">{{ t('common.loading') }}</p>
|
||||
@@ -56,6 +80,16 @@ function formatDateTime(iso: string): string {
|
||||
<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" />
|
||||
<StatTile
|
||||
:label="t('analytics.loginFailureRate')"
|
||||
:value="`${summary.kpis.login_failure_rate_7d}%`"
|
||||
:hint="t('analytics.loginFailureRateHint', { count: summary.kpis.login_failures_7d })"
|
||||
/>
|
||||
<StatTile
|
||||
:label="t('analytics.oauthApprovalRate')"
|
||||
:value="`${summary.oauth_funnel.approval_rate}%`"
|
||||
:hint="t('analytics.oauthApprovalRateHint', { approved: summary.oauth_funnel.approved, attempted: summary.oauth_funnel.attempted })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="analytics__chart" :class="{ 'analytics__section--loading': loading }">
|
||||
@@ -67,10 +101,43 @@ function formatDateTime(iso: string): string {
|
||||
<BarList :title="t('analytics.topUsers')" :items="userItems" :empty-text="t('analytics.noUserActivity')" />
|
||||
</div>
|
||||
|
||||
<div class="card analytics__funnel" :class="{ 'analytics__section--loading': loading }">
|
||||
<h3 class="card__title">{{ t('analytics.funnelTitle') }}</h3>
|
||||
|
||||
<p v-if="!summary.oauth_funnel_by_client.length" class="analytics__recent-count">{{ t('analytics.funnelEmpty') }}</p>
|
||||
<div v-else class="analytics__table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('analytics.colClient') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colAttempted') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colApproved') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colDenied') }}</th>
|
||||
<th class="table__head-cell">{{ t('analytics.colApprovalRate') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="row in summary.oauth_funnel_by_client" :key="row.client_name">
|
||||
<td class="table__cell table__cell--primary">{{ row.client_name }}</td>
|
||||
<td class="table__cell">{{ row.attempted }}</td>
|
||||
<td class="table__cell">{{ row.approved }}</td>
|
||||
<td class="table__cell">{{ row.denied }}</td>
|
||||
<td class="table__cell">{{ row.attempted ? Math.round((row.approved / row.attempted) * 100) : 0 }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
|
||||
<BarList :title="t('analytics.activeTokensByClient')" :items="activeTokenItems" :empty-text="t('analytics.noActiveTokens')" />
|
||||
<BarList :title="t('analytics.scopeUsageTitle')" :items="scopeItems" :empty-text="t('analytics.noScopeUsage')" />
|
||||
</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">
|
||||
<button @click="showTable = !showTable" class="link analytics__toggle no-print">
|
||||
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -109,6 +176,12 @@ function formatDateTime(iso: string): string {
|
||||
--series-2: #eb6834;
|
||||
}
|
||||
|
||||
.analytics__header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics__loading {
|
||||
margin-top: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -132,6 +205,10 @@ function formatDateTime(iso: string): string {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.analytics__funnel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.analytics__recent {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
211
resources/js/pages/admin/AuditLog.vue
Normal file
211
resources/js/pages/admin/AuditLog.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { AuditLogEntry, Paginated } from '../../types/models';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const loading = ref(true);
|
||||
const entries = ref<AuditLogEntry[]>([]);
|
||||
const page = ref(1);
|
||||
const lastPage = ref(1);
|
||||
const total = ref(0);
|
||||
const subjectType = ref<'' | 'user' | 'client'>('');
|
||||
const action = ref('');
|
||||
|
||||
const actionOptions = [
|
||||
'user.created',
|
||||
'user.updated',
|
||||
'user.deleted',
|
||||
'user.restored',
|
||||
'user.force_deleted',
|
||||
'user.approved',
|
||||
'user.app_role_assigned',
|
||||
'user.app_role_revoked',
|
||||
'client.created',
|
||||
'client.revoked',
|
||||
'user.2fa_enabled',
|
||||
'user.2fa_disabled',
|
||||
'user.2fa_recovery_regenerated',
|
||||
'user.session_revoked',
|
||||
'user.sessions_revoked_all',
|
||||
'client.webhook_created',
|
||||
'client.webhook_deleted',
|
||||
'client.secret_rotated',
|
||||
'user.password_reset',
|
||||
];
|
||||
|
||||
const actionLabelKeys: Record<string, string> = {
|
||||
'user.created': 'auditLog.actions.userCreated',
|
||||
'user.updated': 'auditLog.actions.userUpdated',
|
||||
'user.deleted': 'auditLog.actions.userDeleted',
|
||||
'user.restored': 'auditLog.actions.userRestored',
|
||||
'user.force_deleted': 'auditLog.actions.userForceDeleted',
|
||||
'user.approved': 'auditLog.actions.userApproved',
|
||||
'user.app_role_assigned': 'auditLog.actions.appRoleAssigned',
|
||||
'user.app_role_revoked': 'auditLog.actions.appRoleRevoked',
|
||||
'client.created': 'auditLog.actions.clientCreated',
|
||||
'client.revoked': 'auditLog.actions.clientRevoked',
|
||||
'user.2fa_enabled': 'auditLog.actions.twoFactorEnabled',
|
||||
'user.2fa_disabled': 'auditLog.actions.twoFactorDisabled',
|
||||
'user.2fa_recovery_regenerated': 'auditLog.actions.twoFactorRecoveryRegenerated',
|
||||
'user.session_revoked': 'auditLog.actions.sessionRevoked',
|
||||
'user.sessions_revoked_all': 'auditLog.actions.sessionsRevokedAll',
|
||||
'client.webhook_created': 'auditLog.actions.webhookCreated',
|
||||
'client.webhook_deleted': 'auditLog.actions.webhookDeleted',
|
||||
'client.secret_rotated': 'auditLog.actions.secretRotated',
|
||||
'user.password_reset': 'auditLog.actions.passwordReset',
|
||||
};
|
||||
|
||||
function actionLabel(value: string): string {
|
||||
const key = actionLabelKeys[value];
|
||||
return key ? t(key) : value;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
const { data } = await axios.get<Paginated<AuditLogEntry>>('/web-api/admin/audit-log', {
|
||||
params: {
|
||||
page: page.value,
|
||||
...(subjectType.value ? { subject_type: subjectType.value } : {}),
|
||||
...(action.value ? { action: action.value } : {}),
|
||||
},
|
||||
});
|
||||
entries.value = data.data;
|
||||
lastPage.value = data.last_page;
|
||||
total.value = data.total;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function goToPage(next: number): void {
|
||||
if (next < 1 || next > lastPage.value) return;
|
||||
page.value = next;
|
||||
}
|
||||
|
||||
watch([subjectType, action], () => {
|
||||
page.value = 1;
|
||||
load();
|
||||
});
|
||||
watch(page, load);
|
||||
onMounted(load);
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatMetadata(metadata: Record<string, unknown> | null): string {
|
||||
if (!metadata || !Object.keys(metadata).length) return '—';
|
||||
|
||||
return Object.entries(metadata)
|
||||
.map(([key, value]) => {
|
||||
if (value && typeof value === 'object' && 'from' in value && 'to' in value) {
|
||||
const { from, to } = value as { from: unknown; to: unknown };
|
||||
return `${key}: ${from ?? '—'} → ${to ?? '—'}`;
|
||||
}
|
||||
if (value && typeof value === 'object' && 'changed' in value) {
|
||||
return t('auditLog.fieldChanged', { field: key });
|
||||
}
|
||||
return `${key}: ${value}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ t('auditLog.title') }}</h1>
|
||||
<p class="page-subtitle">{{ t('auditLog.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-log__filters">
|
||||
<select v-model="subjectType" class="pill-select">
|
||||
<option value="">{{ t('auditLog.allSubjects') }}</option>
|
||||
<option value="user">{{ t('auditLog.subjectUser') }}</option>
|
||||
<option value="client">{{ t('auditLog.subjectClient') }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="action" class="pill-select">
|
||||
<option value="">{{ t('auditLog.allActions') }}</option>
|
||||
<option v-for="a in actionOptions" :key="a" :value="a">{{ actionLabel(a) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="table-card audit-log__table-card">
|
||||
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!entries.length" class="table__empty">{{ t('auditLog.empty') }}</p>
|
||||
|
||||
<table v-else class="table">
|
||||
<thead class="table__head">
|
||||
<tr>
|
||||
<th class="table__head-cell">{{ t('auditLog.colWhen') }}</th>
|
||||
<th class="table__head-cell">{{ t('auditLog.colActor') }}</th>
|
||||
<th class="table__head-cell">{{ t('auditLog.colAction') }}</th>
|
||||
<th class="table__head-cell">{{ t('auditLog.colSubject') }}</th>
|
||||
<th class="table__head-cell">{{ t('auditLog.colDetails') }}</th>
|
||||
<th class="table__head-cell">{{ t('auditLog.colIp') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__body">
|
||||
<tr v-for="entry in entries" :key="entry.id">
|
||||
<td class="table__cell audit-log__when">{{ formatDateTime(entry.created_at) }}</td>
|
||||
<td class="table__cell">{{ entry.actor_name ?? t('auditLog.systemActor') }}</td>
|
||||
<td class="table__cell table__cell--primary">{{ actionLabel(entry.action) }}</td>
|
||||
<td class="table__cell">{{ entry.subject_label ?? entry.subject_id }}</td>
|
||||
<td class="table__cell audit-log__details">{{ formatMetadata(entry.metadata) }}</td>
|
||||
<td class="table__cell">{{ entry.ip_address }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!loading && entries.length" class="audit-log__pager">
|
||||
<button class="link" :disabled="page <= 1" @click="goToPage(page - 1)">{{ t('auditLog.prevPage') }}</button>
|
||||
<span class="audit-log__pager-info">{{ t('auditLog.pageInfo', { page, lastPage, total }) }}</span>
|
||||
<button class="link" :disabled="page >= lastPage" @click="goToPage(page + 1)">{{ t('auditLog.nextPage') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-log__filters {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-log__table-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.audit-log__when {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-log__details {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-log__pager {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-log__pager button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.audit-log__pager-info {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import type { AppRoleGroup, OAuthApp, User, UserSession } from '../../types/models';
|
||||
|
||||
type View = 'active' | 'pending' | 'trashed';
|
||||
|
||||
@@ -16,6 +16,7 @@ const trashedUsers = ref<User[]>([]);
|
||||
const view = ref<View>('active');
|
||||
const apps = ref<OAuthApp[]>([]);
|
||||
const appRoles = ref<AppRoleGroup[]>([]);
|
||||
const sessions = ref<UserSession[]>([]);
|
||||
const loading = ref(true);
|
||||
const creating = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
@@ -133,6 +134,25 @@ async function removeAppRole(user: User, clientId: string, role: string): Promis
|
||||
await loadAppRoles(user);
|
||||
}
|
||||
|
||||
async function loadSessions(user: User): Promise<void> {
|
||||
const { data } = await axios.get<UserSession[]>(`/web-api/admin/users/${user.id}/sessions`);
|
||||
sessions.value = data;
|
||||
}
|
||||
|
||||
async function revokeSession(user: User, session: UserSession): Promise<void> {
|
||||
if (!confirm(t('adminUsers.revokeSessionConfirm', { name: session.client_name ?? session.name ?? t('adminUsers.sessionUnknownClient') }))) return;
|
||||
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/sessions/${session.id}`);
|
||||
await loadSessions(user);
|
||||
}
|
||||
|
||||
async function revokeAllSessions(user: User): Promise<void> {
|
||||
if (!confirm(t('adminUsers.revokeAllSessionsConfirm', { name: user.name }))) return;
|
||||
|
||||
await axios.delete(`/web-api/admin/users/${user.id}/sessions`);
|
||||
await loadSessions(user);
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
creating.value = true;
|
||||
errors.value = {};
|
||||
@@ -160,7 +180,9 @@ function startEdit(user: User): void {
|
||||
editForm.is_active = user.is_active;
|
||||
errors.value = {};
|
||||
appRoles.value = [];
|
||||
sessions.value = [];
|
||||
loadAppRoles(user);
|
||||
loadSessions(user);
|
||||
}
|
||||
|
||||
function cancelEdit(): void {
|
||||
@@ -357,6 +379,31 @@ onMounted(load);
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users__app-roles">
|
||||
<div class="users__sessions-header">
|
||||
<h3 class="users__app-roles-title">{{ t('adminUsers.sessionsTitle') }}</h3>
|
||||
<button v-if="sessions.length" @click="revokeAllSessions(user)" class="link link--danger">
|
||||
{{ t('adminUsers.revokeAllSessions') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="users__app-roles-desc">{{ t('adminUsers.sessionsDesc') }}</p>
|
||||
|
||||
<div v-if="!sessions.length" class="users__app-roles-empty">{{ t('adminUsers.noSessions') }}</div>
|
||||
<ul v-else class="users__sessions-list">
|
||||
<li v-for="session in sessions" :key="session.id" class="users__session-row">
|
||||
<div>
|
||||
<span class="users__session-client">{{ session.client_name ?? t('adminUsers.sessionUnknownClient') }}</span>
|
||||
<span class="users__session-meta">
|
||||
{{ session.scopes?.join(', ') || t('common.none') }} · {{ formatDate(session.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<button @click="revokeSession(user, session)" class="link link--danger">
|
||||
{{ t('adminUsers.revokeSession') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -553,6 +600,41 @@ onMounted(load);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users__sessions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.users__sessions-list {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users__session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.users__session-client {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.users__session-meta {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.users__form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -28,6 +28,18 @@ const routes = [
|
||||
component: () => import('../pages/Register.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: () => import('../pages/ForgotPassword.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'reset-password',
|
||||
component: () => import('../pages/ResetPassword.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
name: 'clients',
|
||||
@@ -46,6 +58,12 @@ const routes = [
|
||||
component: () => import('../pages/AuthorizedApps.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/security',
|
||||
name: 'security',
|
||||
component: () => import('../pages/Security.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/oauth/authorize',
|
||||
name: 'oauth-authorize',
|
||||
@@ -64,6 +82,12 @@ const routes = [
|
||||
component: () => import('../pages/admin/Analytics.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/audit-log',
|
||||
name: 'admin-audit-log',
|
||||
component: () => import('../pages/admin/AuditLog.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -48,8 +48,21 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
|
||||
async login(payload: Credentials): Promise<string | null> {
|
||||
const { data } = await axios.post<{ user: User; intended: string | null }>('/web-api/login', payload);
|
||||
async login(payload: Credentials): Promise<{ twoFactor: true } | { twoFactor: false; intended: string | null }> {
|
||||
const { data } = await axios.post<{ two_factor?: true; user?: User; intended?: string | null }>('/web-api/login', payload);
|
||||
|
||||
if (data.two_factor) {
|
||||
return { twoFactor: true };
|
||||
}
|
||||
|
||||
this.user = data.user!;
|
||||
this.checked = true;
|
||||
|
||||
return { twoFactor: false, intended: data.intended ?? null };
|
||||
},
|
||||
|
||||
async loginTwoFactor(code: string): Promise<string | null> {
|
||||
const { data } = await axios.post<{ user: User; intended: string | null }>('/web-api/login/2fa', { code });
|
||||
this.user = data.user;
|
||||
this.checked = true;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface User {
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
approved_at?: string | null;
|
||||
two_factor_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OAuthClient {
|
||||
@@ -24,6 +25,31 @@ export interface OAuthClient {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WebhookEndpoint {
|
||||
id: number;
|
||||
client_id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
last_delivery: {
|
||||
event: string;
|
||||
response_status: number | null;
|
||||
delivered_at: string | null;
|
||||
created_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: number;
|
||||
event: string;
|
||||
response_status: number | null;
|
||||
response_body: string | null;
|
||||
attempts: number;
|
||||
delivered_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PersonalAccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -55,6 +81,16 @@ export interface OAuthApp {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
client_name: string | null;
|
||||
client_revoked: boolean;
|
||||
name: string | null;
|
||||
scopes: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface AppRoleGroup {
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
@@ -67,6 +103,8 @@ export interface AnalyticsSummary {
|
||||
active_sessions: number;
|
||||
logins_today: number;
|
||||
logins_7d: number;
|
||||
login_failures_7d: number;
|
||||
login_failure_rate_7d: number;
|
||||
};
|
||||
logins_by_day: Array<{ date: string; session: number; oauth: number }>;
|
||||
logins_by_app: Array<{ client_name: string; count: number }>;
|
||||
@@ -79,6 +117,36 @@ export interface AnalyticsSummary {
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
oauth_funnel: {
|
||||
attempted: number;
|
||||
approved: number;
|
||||
denied: number;
|
||||
approval_rate: number;
|
||||
};
|
||||
oauth_funnel_by_client: Array<{ client_name: string; attempted: number; approved: number; denied: number }>;
|
||||
active_tokens_by_client: Array<{ client_name: string; count: number }>;
|
||||
scope_usage: Array<{ scope: string; description: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
actor_name: string | null;
|
||||
actor_email: string | null;
|
||||
action: string;
|
||||
subject_type: 'user' | 'client';
|
||||
subject_id: string;
|
||||
subject_label: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ip_address: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
data: T[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizePayload {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use 'base/tokens';
|
||||
@use 'base/reset';
|
||||
@use 'base/typography';
|
||||
@use 'base/print';
|
||||
|
||||
@use 'components/page-header';
|
||||
@use 'components/button';
|
||||
|
||||
29
resources/scss/base/_print.scss
Normal file
29
resources/scss/base/_print.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@page {
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// Fuerza tema claro al imprimir (el oscuro desperdicia tinta y el
|
||||
// navegador ya arranca la impresión con .dark sacado del <html>, pero
|
||||
// esto cubre navegadores que no respeten ese cambio a tiempo).
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.card,
|
||||
.table-card,
|
||||
.stat-tile,
|
||||
table,
|
||||
tr {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,16 @@
|
||||
max-width: $page-max-width;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-4;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
padding: $space-3 $space-6;
|
||||
}
|
||||
|
||||
.app-nav__section {
|
||||
.app-nav__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-6;
|
||||
justify-content: space-between;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.app-nav__brand {
|
||||
@@ -37,15 +37,9 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-nav__brand-name {
|
||||
@@ -57,6 +51,7 @@
|
||||
.app-nav__links {
|
||||
display: none;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-1;
|
||||
|
||||
@include respond-up($breakpoint-md) {
|
||||
@@ -70,6 +65,7 @@
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
transition: $transition-fast;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -40,16 +40,9 @@
|
||||
}
|
||||
|
||||
.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%);
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.auth-card__title {
|
||||
|
||||
@@ -22,3 +22,7 @@
|
||||
.badge--inactive {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.badge--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
display: block;
|
||||
margin-top: $space-1;
|
||||
font-size: $font-xs;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OAuth2 Server</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<script>
|
||||
// Aplica el tema ANTES del primer paint (evita el flash del tema equivocado).
|
||||
// Prioridad: preferencia guardada > prefers-color-scheme del SO.
|
||||
|
||||
Reference in New Issue
Block a user