Initial commit: OAuth IAM Laravel application

Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and
Docker Compose setup for local and production deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 01:02:41 +02:00
commit c2b5291a74
174 changed files with 29318 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
interface DailyPoint {
date: string;
session: number;
oauth: number;
}
const props = defineProps<{
data: DailyPoint[];
}>();
const { t, locale } = useI18n();
const width = 720;
const height = 220;
const padLeft = 34;
const padBottom = 22;
const padTop = 10;
const plotW = width - padLeft - 8;
const plotH = height - padTop - padBottom;
const maxTotal = computed(() => Math.max(4, ...props.data.map((d) => d.session + d.oauth)));
const niceMax = computed(() => {
// redondea el techo del eje Y a un número "lindo"
const raw = maxTotal.value;
const magnitude = Math.pow(10, Math.floor(Math.log10(raw || 1)));
const step = Math.ceil(raw / magnitude) * magnitude;
return step <= 0 ? 4 : step;
});
const yTicks = computed(() => [0, 0.5, 1].map((f) => Math.round(niceMax.value * f)));
const barW = computed(() => Math.max(2, plotW / props.data.length - 2));
function x(i: number): number {
return padLeft + (i * plotW) / props.data.length;
}
function yFor(value: number): number {
return padTop + plotH - (value / niceMax.value) * plotH;
}
// Rect con SOLO las puntas de arriba redondeadas (4px) y la base cuadrada,
// para que el extremo libre del stack se redondee pero la base (donde se
// apoya en el baseline, o en el segmento de abajo) quede a escuadra.
function topRoundedRect(xPos: number, y: number, w: number, h: number): string {
const r = Math.min(4, w / 2, Math.max(0, h));
if (h <= 0) return '';
if (r <= 0) return `M${xPos},${y} h${w} v${h} h${-w} Z`;
return [
`M${xPos},${y + h}`,
`V${y + r}`,
`Q${xPos},${y} ${xPos + r},${y}`,
`H${xPos + w - r}`,
`Q${xPos + w},${y} ${xPos + w},${y + r}`,
`V${y + h}`,
'Z',
].join(' ');
}
const hovered = ref<number | null>(null);
function labelFor(i: number): string {
const d = props.data[i];
if (!d) return '';
const dt = new Date(d.date + 'T00:00:00');
return dt.toLocaleDateString(locale.value, { day: '2-digit', month: 'short' });
}
function ariaLabelFor(i: number): string {
const d = props.data[i];
if (!d) return '';
return t('analytics.barAriaLabel', { date: labelFor(i), oauth: d.oauth, session: d.session });
}
const showEveryNth = computed(() => Math.ceil(props.data.length / 6));
const tooltipStyle = computed(() => {
if (hovered.value === null) return { display: 'none' };
const left = x(hovered.value) + barW.value / 2;
const clampedLeft = Math.min(Math.max(left, 70), width - 70);
return {
left: (clampedLeft / width) * 100 + '%',
top: '4px',
};
});
</script>
<template>
<div class="daily-chart">
<div class="daily-chart__header">
<h3 class="daily-chart__title">{{ t('analytics.loginsByDay') }}</h3>
<div class="daily-chart__legend">
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--oauth"></span> {{ t('analytics.appsOAuth') }}</span>
<span class="daily-chart__legend-item"><span class="daily-chart__swatch daily-chart__swatch--session"></span> {{ t('analytics.panelAdmin') }}</span>
</div>
</div>
<div class="daily-chart__body">
<svg :viewBox="`0 0 ${width} ${height}`" class="daily-chart__svg" role="img" :aria-label="t('analytics.chartAriaLabel')">
<!-- gridlines -->
<g>
<line
v-for="tick in yTicks"
:key="tick"
:x1="padLeft"
:x2="width - 4"
:y1="yFor(tick)"
:y2="yFor(tick)"
class="daily-chart__gridline"
stroke-width="1"
/>
<text v-for="tick in yTicks" :key="'l' + tick" :x="padLeft - 6" :y="yFor(tick) + 3" text-anchor="end" class="daily-chart__axis-text" font-size="9">
{{ tick }}
</text>
</g>
<!-- bars -->
<g v-for="(d, i) in data" :key="d.date">
<!-- oauth segment (bottom, apoyado en el baseline -> cuadrado).
Si no hay segmento de sesión arriba, esta punta es la libre: se redondea. -->
<path
v-if="d.session > 0"
:d="`M${x(i)},${yFor(0)} h${barW} v${-(Math.max(0, yFor(0) - yFor(d.oauth)))} h${-barW} Z`"
class="daily-chart__bar daily-chart__bar--oauth"
:opacity="hovered === null || hovered === i ? 1 : 0.35"
/>
<path
v-else
:d="topRoundedRect(x(i), yFor(d.oauth), barW, Math.max(0, yFor(0) - yFor(d.oauth)))"
class="daily-chart__bar daily-chart__bar--oauth"
:opacity="hovered === null || hovered === i ? 1 : 0.35"
/>
<!-- session segment (stacked on top, 2px surface gap, punta libre redondeada) -->
<path
v-if="d.session > 0"
:d="topRoundedRect(x(i), yFor(d.oauth + d.session), barW, Math.max(0, yFor(d.oauth) - yFor(d.oauth + d.session) - 2))"
class="daily-chart__bar daily-chart__bar--session"
:opacity="hovered === null || hovered === i ? 1 : 0.35"
/>
<!-- hit target: toda la columna, más ancho que la barra visible -->
<rect
:x="x(i) - 1"
:y="padTop"
:width="barW + 2"
:height="plotH"
class="daily-chart__hit"
@mouseenter="hovered = i"
@mouseleave="hovered = null"
@focus="hovered = i"
@blur="hovered = null"
tabindex="0"
:aria-label="ariaLabelFor(i)"
/>
<text
v-if="i % showEveryNth === 0"
:x="x(i) + barW / 2"
:y="height - 4"
text-anchor="middle"
class="daily-chart__axis-text"
font-size="9"
>
{{ labelFor(i) }}
</text>
</g>
<!-- baseline -->
<line :x1="padLeft" :x2="width - 4" :y1="yFor(0)" :y2="yFor(0)" class="daily-chart__baseline" stroke-width="1" />
</svg>
<div v-if="hovered !== null" class="daily-chart__tooltip" :style="tooltipStyle">
<p class="daily-chart__tooltip-date">{{ labelFor(hovered) }}</p>
<p class="daily-chart__tooltip-row">
<span class="daily-chart__swatch daily-chart__swatch--oauth daily-chart__swatch--sm"></span>
{{ t('analytics.tooltipAppLabel') }} <strong>{{ data[hovered].oauth }}</strong>
</p>
<p class="daily-chart__tooltip-row">
<span class="daily-chart__swatch daily-chart__swatch--session daily-chart__swatch--sm"></span>
{{ t('analytics.tooltipSessionLabel') }} <strong>{{ data[hovered].session }}</strong>
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// Este componente usa <style scoped>, así que el mixin "dark" global (pensado
// para partials no-scoped) no aplica acá: hace falta ":global(.dark) &"
// directo para atravesar el compilador scoped de Vue.
.daily-chart {
--series-1: #2a78d6;
--series-2: #eb6834;
--muted: #898781;
--gridline: #e1e0d9;
--axis: #c3c2b7;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem;
:global(.dark) & {
--series-1: #3987e5;
--series-2: #d95926;
--gridline: #2c2c2a;
--axis: #383835;
}
}
.daily-chart__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.daily-chart__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
}
.daily-chart__legend {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.daily-chart__legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
.daily-chart__swatch {
display: inline-block;
height: 0.5rem;
width: 0.75rem;
border-radius: 0.125rem;
&--oauth {
background: var(--series-1);
}
&--session {
background: var(--series-2);
}
&--sm {
height: 0.5rem;
width: 0.5rem;
}
}
.daily-chart__body {
position: relative;
margin-top: 0.75rem;
}
.daily-chart__svg {
width: 100%;
}
.daily-chart__gridline {
stroke: var(--gridline);
}
.daily-chart__baseline {
stroke: var(--axis);
}
.daily-chart__axis-text {
fill: var(--muted);
}
.daily-chart__bar {
&--oauth {
fill: var(--series-1);
}
&--session {
fill: var(--series-2);
}
}
.daily-chart__hit {
fill: transparent;
cursor: pointer;
}
.daily-chart__tooltip {
pointer-events: none;
position: absolute;
transform: translateX(-50%);
border-radius: 0.5rem;
border: 1px solid var(--color-border);
background: var(--color-surface);
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
box-shadow: var(--shadow-elevated);
}
.daily-chart__tooltip-date {
font-weight: 500;
color: var(--color-text-primary);
}
.daily-chart__tooltip-row {
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-secondary);
strong {
color: var(--color-text-primary);
}
}
</style>