feat(auth,dashboard): 2FA mobile session persistence and sparkline cards
- Persist 2FA state in sessionStorage to survive mobile page reloads - Reuse existing valid OTP on re-login to avoid rate limiting and duplicate emails - Add embedded sparkline charts to SolduriCompactCard with expand toggle - Mobile dashboard redesigned: 2 pages with enriched compact cards + cashflow type - Login UI simplified: remove gradient bg, subtitle, icon; use design tokens - Focus OTP input when session is restored from 2FA state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -406,6 +406,17 @@ def create_auth_router(
|
||||
|
||||
# Pas 4: Dacă are email → trimitem OTP (2FA)
|
||||
if user_email:
|
||||
# Check for existing valid OTP (mobile page reload scenario)
|
||||
existing_entry = get_otp_entry(user_email)
|
||||
|
||||
if existing_entry:
|
||||
# OTP already exists and is valid — skip generation and email
|
||||
logger.info(
|
||||
f"[2FA] Reusing existing OTP for {user_email[:3]}*** "
|
||||
f"(user='{actual_username}', skipping email)"
|
||||
)
|
||||
else:
|
||||
# Generate new OTP
|
||||
code = await create_otp(user_email, actual_username, login_data.server_id)
|
||||
|
||||
if code is None:
|
||||
|
||||
@@ -367,14 +367,12 @@
|
||||
|
||||
.metric-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--card-radius);
|
||||
padding: var(--card-padding, 1.5rem);
|
||||
padding: var(--space-xs);
|
||||
transition: all var(--transition-fast);
|
||||
min-height: var(--card-min-height, 200px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--card-gap, 1rem);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
@@ -425,8 +423,7 @@
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.metric-card {
|
||||
min-height: calc(var(--card-min-height, 200px) - 40px);
|
||||
padding: var(--card-padding-sm, 1rem);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
.metrics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
@@ -66,16 +66,16 @@
|
||||
|
||||
/* ===== Breakdown Patterns ===== */
|
||||
.breakdown-section {
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-xs);
|
||||
border-top: 1px dotted var(--color-border);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.breakdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
@@ -99,7 +99,7 @@
|
||||
.breakdown-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xs) 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.breakdown-sublabel {
|
||||
@@ -187,5 +187,5 @@
|
||||
.breakdown-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: var(--space-md) 0;
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
@@ -610,10 +610,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
/* Component-specific: Dual-chart layout for CashFlowMetricCard */
|
||||
|
||||
/* Override min-height for dual chart layout */
|
||||
.cashflow-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
/* Ultra-compact cashflow card */
|
||||
|
||||
/* Metric label and value typography */
|
||||
.metric-label {
|
||||
@@ -631,7 +628,7 @@ onBeforeUnmount(() => {
|
||||
.values-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
gap: 2px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -646,8 +643,8 @@ onBeforeUnmount(() => {
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: var(--color-border);
|
||||
min-height: 60px;
|
||||
border-left: 1px dotted var(--color-border);
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Charts toggle header */
|
||||
@@ -715,10 +712,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Responsive: Stack vertically on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.cashflow-card {
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.values-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -462,10 +462,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
/* Component-specific: ClientiBalanceCard layout and breakdown */
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.clienti-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
/* Ultra-compact clienti balance card */
|
||||
|
||||
/* Mobile header with total and trend */
|
||||
.card-header-mobile {
|
||||
@@ -474,7 +471,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
padding: var(--space-sm) 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
border-bottom: 1px dotted var(--surface-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -643,10 +640,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.clienti-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
@@ -462,10 +462,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
/* Component-specific: FurnizoriBalanceCard layout and breakdown */
|
||||
|
||||
/* Override min-height for balance card */
|
||||
.furnizori-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
/* Ultra-compact furnizori balance card */
|
||||
|
||||
/* Mobile header with total and trend */
|
||||
.card-header-mobile {
|
||||
@@ -474,7 +471,7 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
padding: var(--space-sm) 0;
|
||||
margin-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
border-bottom: 1px dotted var(--surface-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -643,10 +640,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.furnizori-balance-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
@@ -608,10 +608,7 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
/* Component-specific: TreasuryDualCard (Casa | Bancă) */
|
||||
|
||||
/* Override min-height for treasury card */
|
||||
.treasury-dual-card {
|
||||
min-height: 320px;
|
||||
}
|
||||
/* Ultra-compact treasury card */
|
||||
|
||||
/* Treasury items container - stacked vertical */
|
||||
.treasury-items {
|
||||
@@ -622,8 +619,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Treasury group (Casa sau Bancă) */
|
||||
.treasury-group {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
border-bottom: 1px dotted var(--surface-border);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -632,10 +630,10 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-md);
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--surface-ground);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
@@ -673,16 +671,14 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Treasury sub-items */
|
||||
.treasury-subitems {
|
||||
padding: var(--space-sm) var(--space-md) var(--space-md);
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding: 0 0 4px var(--space-md);
|
||||
}
|
||||
|
||||
.treasury-subitem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.treasury-sublabel {
|
||||
@@ -768,24 +764,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* Responsive: Stack vertically on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.sparkline-chart {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.treasury-dual-card {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.treasury-header {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.treasury-value {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
@@ -29,15 +29,31 @@
|
||||
|
||||
<!-- Expandable Breakdown Section -->
|
||||
<div v-if="isExpanded && hasBreakdown" class="solduri-compact-card__breakdown">
|
||||
<!-- CashFlow: Încasări + Plăți -->
|
||||
<template v-if="type === 'cashflow'">
|
||||
<div class="solduri-compact-card__breakdown-item">
|
||||
<span class="solduri-compact-card__breakdown-label">Încasări</span>
|
||||
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--success">
|
||||
{{ formatAmount((breakdown as CashFlowBreakdown)?.incasari || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="solduri-compact-card__breakdown-item">
|
||||
<span class="solduri-compact-card__breakdown-label">Plăți</span>
|
||||
<span class="solduri-compact-card__breakdown-value solduri-compact-card__breakdown-value--danger">
|
||||
{{ formatAmount((breakdown as CashFlowBreakdown)?.plati || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trezorerie: Casa + Bancă -->
|
||||
<template v-if="type === 'trezorerie'">
|
||||
<template v-else-if="type === 'trezorerie'">
|
||||
<!-- Casa Total -->
|
||||
<div class="solduri-compact-card__breakdown-item">
|
||||
<span class="solduri-compact-card__breakdown-label">Casa</span>
|
||||
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(casaTotal) }}</span>
|
||||
</div>
|
||||
<!-- Sub-conturi Casa (imediat sub Casa) -->
|
||||
<template v-if="breakdown?.casa?.items?.length">
|
||||
<!-- Sub-conturi Casa (level 2 only) -->
|
||||
<template v-if="showSubDetails && breakdown?.casa?.items?.length">
|
||||
<div
|
||||
v-for="(item, idx) in breakdown.casa.items"
|
||||
:key="`casa-${idx}`"
|
||||
@@ -54,8 +70,8 @@
|
||||
<span class="solduri-compact-card__breakdown-label">Bancă</span>
|
||||
<span class="solduri-compact-card__breakdown-value">{{ formatAmount(bancaTotal) }}</span>
|
||||
</div>
|
||||
<!-- Sub-conturi Bancă (imediat sub Bancă) -->
|
||||
<template v-if="breakdown?.banca?.items?.length">
|
||||
<!-- Sub-conturi Bancă (level 2 only) -->
|
||||
<template v-if="showSubDetails && breakdown?.banca?.items?.length">
|
||||
<div
|
||||
v-for="(item, idx) in breakdown.banca.items"
|
||||
:key="`banca-${idx}`"
|
||||
@@ -83,8 +99,8 @@
|
||||
{{ formatAmount(breakdown?.restant?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Perioade restante -->
|
||||
<template v-if="breakdown?.restant?.perioade">
|
||||
<!-- Perioade restante (level 2 only) -->
|
||||
<template v-if="showSubDetails && breakdown?.restant?.perioade">
|
||||
<div
|
||||
v-for="(value, key) in breakdown.restant.perioade"
|
||||
:key="key"
|
||||
@@ -169,14 +185,61 @@
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Chart toggle - only shown when card is expanded and chartConfig is provided -->
|
||||
<div
|
||||
v-if="isExpanded && chartConfig"
|
||||
class="solduri-compact-card__charts-toggle"
|
||||
@click.stop="toggleChartExpanded"
|
||||
>
|
||||
<span class="solduri-compact-card__charts-toggle-label">Grafice evoluție</span>
|
||||
<i
|
||||
class="pi pi-chevron-right solduri-compact-card__charts-chevron"
|
||||
:class="{ 'solduri-compact-card__charts-chevron--expanded': isChartExpanded }"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Chart content - lazy rendered with v-if to avoid zero-dimension canvas -->
|
||||
<div v-if="isExpanded && isChartExpanded && chartConfig" class="solduri-compact-card__charts-content">
|
||||
<!-- Dual charts (Trezorerie: Casa + Banca) -->
|
||||
<template v-if="chartConfig.dual">
|
||||
<div class="solduri-compact-card__chart-wrapper">
|
||||
<div class="solduri-compact-card__chart-label" :style="{ color: chartConfig.dual.primaryColor }">
|
||||
{{ chartConfig.dual.primaryLabel }}
|
||||
</div>
|
||||
<div class="solduri-compact-card__chart-container">
|
||||
<canvas ref="chartCanvas1"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="solduri-compact-card__chart-wrapper">
|
||||
<div class="solduri-compact-card__chart-label" :style="{ color: chartConfig.dual.secondaryColor }">
|
||||
{{ chartConfig.dual.secondaryLabel }}
|
||||
</div>
|
||||
<div class="solduri-compact-card__chart-container">
|
||||
<canvas ref="chartCanvas2"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Single chart (Clienti/Furnizori) -->
|
||||
<template v-else-if="chartConfig.single">
|
||||
<div class="solduri-compact-card__chart-wrapper">
|
||||
<div class="solduri-compact-card__chart-container">
|
||||
<canvas ref="chartCanvas1"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
// Type definitions
|
||||
type CardType = 'trezorerie' | 'clienti' | 'furnizori' | 'tva'
|
||||
type CardType = 'trezorerie' | 'clienti' | 'furnizori' | 'tva' | 'cashflow'
|
||||
|
||||
interface TrezorerieBreakdown {
|
||||
casa?: {
|
||||
@@ -198,7 +261,33 @@ interface ClientiFurnizoriBreakdown {
|
||||
}
|
||||
}
|
||||
|
||||
type BreakdownType = TrezorerieBreakdown | ClientiFurnizoriBreakdown | null
|
||||
interface CashFlowBreakdown {
|
||||
incasari: number
|
||||
plati: number
|
||||
}
|
||||
|
||||
type BreakdownType = TrezorerieBreakdown | ClientiFurnizoriBreakdown | CashFlowBreakdown | null
|
||||
|
||||
interface ChartConfig {
|
||||
dual?: {
|
||||
primaryData: number[]
|
||||
primaryPreviousData?: number[]
|
||||
primaryLabel: string
|
||||
primaryColor: string
|
||||
secondaryData: number[]
|
||||
secondaryPreviousData?: number[]
|
||||
secondaryLabel: string
|
||||
secondaryColor: string
|
||||
}
|
||||
single?: {
|
||||
data: number[]
|
||||
previousData?: number[]
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
labels: string[]
|
||||
previousLabels?: string[]
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
@@ -208,10 +297,13 @@ const props = defineProps<{
|
||||
breakdown?: BreakdownType
|
||||
casaTotal?: number
|
||||
bancaTotal?: number
|
||||
chartConfig?: ChartConfig
|
||||
}>()
|
||||
|
||||
// State
|
||||
const isExpanded = ref(false)
|
||||
// State - 3-level expansion cycle: 0 (collapsed) → 1 (totals) → 2 (sub-details) → 0
|
||||
const expansionLevel = ref(0)
|
||||
const isExpanded = computed(() => expansionLevel.value > 0)
|
||||
const showSubDetails = computed(() => expansionLevel.value >= 2)
|
||||
const expandedGroups = ref(new Set<string>())
|
||||
const toggleGroup = (key: string) => {
|
||||
if (expandedGroups.value.has(key)) {
|
||||
@@ -222,13 +314,242 @@ const toggleGroup = (key: string) => {
|
||||
expandedGroups.value = new Set(expandedGroups.value)
|
||||
}
|
||||
|
||||
// Chart state
|
||||
const isChartExpanded = ref(false)
|
||||
const chartCanvas1 = ref<HTMLCanvasElement | null>(null)
|
||||
const chartCanvas2 = ref<HTMLCanvasElement | null>(null)
|
||||
let chartInstance1: Chart | null = null
|
||||
let chartInstance2: Chart | null = null
|
||||
|
||||
const toggleChartExpanded = () => {
|
||||
isChartExpanded.value = !isChartExpanded.value
|
||||
}
|
||||
|
||||
// Helper: hex to rgba
|
||||
const hexToRgba = (hex: string, alpha: number): string => {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
// Create a sparkline chart on a canvas element
|
||||
const createSparklineChart = (
|
||||
canvas: HTMLCanvasElement,
|
||||
data: number[],
|
||||
previousData: number[] | undefined,
|
||||
color: string,
|
||||
label: string
|
||||
): Chart | null => {
|
||||
if (!canvas || !data || data.length === 0) return null
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
|
||||
const labels = props.chartConfig?.labels || data.map((_, i) => `L${i + 1}`)
|
||||
|
||||
const datasets: any[] = [{
|
||||
label: `${label} (curent)`,
|
||||
data: data,
|
||||
borderColor: color,
|
||||
backgroundColor: hexToRgba(color, 0.1),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: color,
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2,
|
||||
}]
|
||||
|
||||
if (previousData && previousData.length > 0) {
|
||||
datasets.push({
|
||||
label: `${label} (an precedent)`,
|
||||
data: previousData,
|
||||
borderColor: hexToRgba(color, 0.4),
|
||||
backgroundColor: hexToRgba(color, 0.05),
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 5],
|
||||
fill: false,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: hexToRgba(color, 0.6),
|
||||
pointHoverBorderColor: '#ffffff',
|
||||
pointHoverBorderWidth: 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Y-axis: symmetric range with padding (from ClientiBalanceCard pattern)
|
||||
const allDataPoints = [...data]
|
||||
if (previousData && previousData.length > 0) allDataPoints.push(...previousData)
|
||||
const dataMin = Math.min(...allDataPoints)
|
||||
const dataMax = Math.max(...allDataPoints)
|
||||
const dataRange = dataMax - dataMin
|
||||
const dataMean = allDataPoints.reduce((sum, val) => sum + val, 0) / allDataPoints.length
|
||||
const minVisibleRange = dataMean * 0.25
|
||||
const center = (dataMin + dataMax) / 2
|
||||
const targetRange = Math.max(dataRange, minVisibleRange)
|
||||
const calculatedMin = center - targetRange / 2
|
||||
const calculatedMax = center + targetRange / 2
|
||||
const paddingAmount = targetRange * 0.1
|
||||
const allPositive = dataMin >= 0
|
||||
const yMin = allPositive ? Math.max(0, calculatedMin - paddingAmount) : calculatedMin - paddingAmount
|
||||
const yMax = calculatedMax + paddingAmount
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
boxWidth: 12, boxHeight: 12, padding: 8,
|
||||
font: { size: 10, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
|
||||
color: 'rgba(107, 114, 128, 0.9)',
|
||||
usePointStyle: true, pointStyle: 'line',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (context: any) => context[0].label || '',
|
||||
label: (context: any) => {
|
||||
const value = context.parsed.y
|
||||
const lbl = context.dataset.label || ''
|
||||
const formatted = new Intl.NumberFormat('ro-RO', {
|
||||
style: 'decimal',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
return `${lbl}: ${formatted}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: {
|
||||
color: 'rgba(107, 114, 128, 0.7)',
|
||||
font: { size: 10, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
border: { display: false },
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
grid: { color: 'rgba(107, 114, 128, 0.1)', drawBorder: false },
|
||||
ticks: {
|
||||
color: color,
|
||||
font: { size: 11, family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
|
||||
maxTicksLimit: 3,
|
||||
callback: function(value: number | string) {
|
||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(0) + 'k'
|
||||
return num.toFixed(0)
|
||||
},
|
||||
},
|
||||
border: { display: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create all charts based on chartConfig
|
||||
const createCharts = () => {
|
||||
destroyCharts()
|
||||
const config = props.chartConfig
|
||||
if (!config) return
|
||||
|
||||
if (config.dual) {
|
||||
if (chartCanvas1.value) {
|
||||
chartInstance1 = createSparklineChart(
|
||||
chartCanvas1.value,
|
||||
config.dual.primaryData,
|
||||
config.dual.primaryPreviousData,
|
||||
config.dual.primaryColor,
|
||||
config.dual.primaryLabel
|
||||
)
|
||||
}
|
||||
if (chartCanvas2.value) {
|
||||
chartInstance2 = createSparklineChart(
|
||||
chartCanvas2.value,
|
||||
config.dual.secondaryData,
|
||||
config.dual.secondaryPreviousData,
|
||||
config.dual.secondaryColor,
|
||||
config.dual.secondaryLabel
|
||||
)
|
||||
}
|
||||
} else if (config.single) {
|
||||
if (chartCanvas1.value) {
|
||||
chartInstance1 = createSparklineChart(
|
||||
chartCanvas1.value,
|
||||
config.single.data,
|
||||
config.single.previousData,
|
||||
config.single.color,
|
||||
config.single.label
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy chart instances
|
||||
const destroyCharts = () => {
|
||||
if (chartInstance1) { chartInstance1.destroy(); chartInstance1 = null }
|
||||
if (chartInstance2) { chartInstance2.destroy(); chartInstance2 = null }
|
||||
}
|
||||
|
||||
// Reset chart state when card is collapsed
|
||||
watch(expansionLevel, (level) => {
|
||||
if (level === 0) {
|
||||
isChartExpanded.value = false
|
||||
destroyCharts()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch chart expansion - lazy initialize charts
|
||||
watch(isChartExpanded, async (expanded) => {
|
||||
if (expanded && props.chartConfig) {
|
||||
await nextTick()
|
||||
createCharts()
|
||||
} else {
|
||||
destroyCharts()
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onBeforeUnmount(() => {
|
||||
destroyCharts()
|
||||
})
|
||||
|
||||
// Computed: Label based on type
|
||||
const label = computed(() => {
|
||||
const labels: Record<CardType, string> = {
|
||||
trezorerie: 'TREZORERIE',
|
||||
clienti: 'CLIENȚI',
|
||||
furnizori: 'FURNIZORI',
|
||||
tva: 'DATORII BUGET'
|
||||
tva: 'DATORII BUGET',
|
||||
cashflow: 'CASH FLOW'
|
||||
}
|
||||
return labels[props.type] || props.type.toUpperCase()
|
||||
})
|
||||
@@ -242,6 +563,11 @@ const valueColorClass = computed(() => {
|
||||
? 'solduri-compact-card__value--danger'
|
||||
: 'solduri-compact-card__value--success'
|
||||
}
|
||||
if (props.type === 'cashflow') {
|
||||
return props.total >= 0
|
||||
? 'solduri-compact-card__value--success'
|
||||
: 'solduri-compact-card__value--danger'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -264,14 +590,25 @@ const hasBreakdown = computed(() => {
|
||||
if (props.type === 'tva') {
|
||||
return props.breakdown !== null && props.breakdown !== undefined
|
||||
}
|
||||
if (props.type === 'cashflow') {
|
||||
return props.breakdown !== null && props.breakdown !== undefined
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Max expansion levels per card type
|
||||
const maxExpansionLevel = computed(() => {
|
||||
// TVA and cashflow: only 1 level (0 → 1 → 0)
|
||||
if (props.type === 'tva' || props.type === 'cashflow') return 1
|
||||
// Trezorerie, clienti, furnizori: 2 levels (0 → 1 → 2 → 0)
|
||||
return 2
|
||||
})
|
||||
|
||||
// Methods
|
||||
const toggleExpanded = () => {
|
||||
if (hasBreakdown.value) {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
if (!hasBreakdown.value) return
|
||||
const next = expansionLevel.value + 1
|
||||
expansionLevel.value = next > maxExpansionLevel.value ? 0 : next
|
||||
}
|
||||
|
||||
const formatAmount = (amount: number | undefined | null): string => {
|
||||
@@ -563,4 +900,76 @@ const formatPeriodLabel = (key: string): string => {
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chart toggle */
|
||||
.solduri-compact-card__charts-toggle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.solduri-compact-card__charts-toggle:hover {
|
||||
background: var(--surface-border);
|
||||
}
|
||||
|
||||
.solduri-compact-card__charts-toggle-label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.solduri-compact-card__charts-chevron {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.solduri-compact-card__charts-chevron--expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Chart content */
|
||||
.solduri-compact-card__charts-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.solduri-compact-card__chart-wrapper {
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.solduri-compact-card__chart-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.solduri-compact-card__chart-container {
|
||||
height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.solduri-compact-card__chart-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive - Chart adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.solduri-compact-card__chart-container {
|
||||
height: 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<!-- Secțiune Carduri Noi - Adăugare -->
|
||||
<div class="metrics-cards-section">
|
||||
<!-- Mobile: Swipeable KPI Cards Carousel -->
|
||||
<!-- US-2002: 6 pages - first page is 2x2 grid with solduri, pages 2-5 are original graph cards, page 6 is financial indicators -->
|
||||
<SwipeableCards v-if="isMobile" :totalCards="6" :fixed-dots="true" :fill-height="true" class="mobile-kpi-carousel">
|
||||
<!-- Page 1: Grid 2x2 cu Solduri Compacte -->
|
||||
<!-- 2 pages: enriched compact cards with embedded charts + financial indicators -->
|
||||
<SwipeableCards v-if="isMobile" :totalCards="2" :fixed-dots="true" :fill-height="true" class="mobile-kpi-carousel">
|
||||
<!-- Page 1: Compact cards with embedded sparkline charts -->
|
||||
<template #card-0>
|
||||
<div class="solduri-grid-2x2">
|
||||
<SolduriCompactCard
|
||||
@@ -60,16 +60,65 @@
|
||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||
:breakdown="treasuryData?.breakdown"
|
||||
:chartConfig="{
|
||||
dual: {
|
||||
primaryData: casaSparkline,
|
||||
primaryPreviousData: casaPreviousSparkline,
|
||||
primaryLabel: 'Casa',
|
||||
primaryColor: '#10b981',
|
||||
secondaryData: bancaSparkline,
|
||||
secondaryPreviousData: bancaPreviousSparkline,
|
||||
secondaryLabel: 'Bancă',
|
||||
secondaryColor: '#3b82f6'
|
||||
},
|
||||
labels: sparklineLabels
|
||||
}"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="cashflow"
|
||||
:total="netCashFlow"
|
||||
:breakdown="{ incasari: monthlyInflows, plati: monthlyOutflows }"
|
||||
:chartConfig="{
|
||||
dual: {
|
||||
primaryData: inflowsSparkline,
|
||||
primaryPreviousData: inflowsPreviousSparkline,
|
||||
primaryLabel: 'Încasări',
|
||||
primaryColor: '#10b981',
|
||||
secondaryData: outflowsSparkline,
|
||||
secondaryPreviousData: outflowsPreviousSparkline,
|
||||
secondaryLabel: 'Plăți',
|
||||
secondaryColor: '#ef4444'
|
||||
},
|
||||
labels: sparklineLabels
|
||||
}"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="clienti"
|
||||
:total="netBalanceData?.clienti_total || 0"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
:chartConfig="{
|
||||
single: {
|
||||
data: clientiSparkline,
|
||||
previousData: clientiPreviousSparkline,
|
||||
label: 'Clienți',
|
||||
color: '#10b981'
|
||||
},
|
||||
labels: sparklineLabels
|
||||
}"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="furnizori"
|
||||
:total="netBalanceData?.furnizori_total || 0"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
:chartConfig="{
|
||||
single: {
|
||||
data: furnizoriSparkline,
|
||||
previousData: furnizoriPreviousSparkline,
|
||||
label: 'Furnizori',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
labels: sparklineLabels
|
||||
}"
|
||||
/>
|
||||
<SolduriCompactCard
|
||||
type="tva"
|
||||
@@ -79,68 +128,8 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Page 2: TreasuryDualCard (original graph card) -->
|
||||
<!-- Page 2: FinancialIndicatorsCard (US-015) -->
|
||||
<template #card-1>
|
||||
<TreasuryDualCard
|
||||
:casaTotal="treasuryData?.breakdown?.casa?.total || 0"
|
||||
:bancaTotal="treasuryData?.breakdown?.banca?.total || 0"
|
||||
:casaItems="treasuryData?.breakdown?.casa?.items || []"
|
||||
:bancaItems="treasuryData?.breakdown?.banca?.items || []"
|
||||
:casaTrend="casaTrend"
|
||||
:bancaTrend="bancaTrend"
|
||||
:casaSparklineData="casaSparkline"
|
||||
:bancaSparklineData="bancaSparkline"
|
||||
:casaPreviousSparklineData="casaPreviousSparkline"
|
||||
:bancaPreviousSparklineData="bancaPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="treasuryCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 3: CashFlowMetricCard (original graph card) -->
|
||||
<template #card-2>
|
||||
<CashFlowMetricCard
|
||||
:inflowsValue="monthlyInflows"
|
||||
:outflowsValue="monthlyOutflows"
|
||||
:inflowsTrend="inflowsTrend"
|
||||
:outflowsTrend="outflowsTrend"
|
||||
:inflowsSparkline="inflowsSparkline"
|
||||
:outflowsSparkline="outflowsSparkline"
|
||||
:inflowsPreviousSparkline="inflowsPreviousSparkline"
|
||||
:outflowsPreviousSparkline="outflowsPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:cacheInfo="cashflowCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 4: ClientiBalanceCard (original graph card) -->
|
||||
<template #card-3>
|
||||
<ClientiBalanceCard
|
||||
:total="netBalanceData?.clienti_total || 0"
|
||||
:trend="clientiTrend"
|
||||
:sparklineData="clientiSparkline"
|
||||
:previousSparklineData="clientiPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.clienti"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 5: FurnizoriBalanceCard (original graph card) -->
|
||||
<template #card-4>
|
||||
<FurnizoriBalanceCard
|
||||
:total="netBalanceData?.furnizori_total || 0"
|
||||
:trend="furnizoriTrend"
|
||||
:sparklineData="furnizoriSparkline"
|
||||
:previousSparklineData="furnizoriPreviousSparkline"
|
||||
:sparklineLabels="sparklineLabels"
|
||||
:previousSparklineLabels="previousSparklineLabels"
|
||||
:breakdown="netBalanceData?.breakdown?.furnizori"
|
||||
:cacheInfo="netBalanceCacheInfo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Page 6: FinancialIndicatorsCard (US-015) -->
|
||||
<template #card-5>
|
||||
<FinancialIndicatorsCard
|
||||
:loading="dashboardStore.financialIndicators.loading"
|
||||
:error="dashboardStore.financialIndicators.error"
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<Card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<i :class="['pi', appIcon, 'text-primary', 'text-6xl']"></i>
|
||||
<h1 class="login-title">{{ appTitle }}</h1>
|
||||
<p class="login-subtitle">{{ appSubtitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">{{ appTitle }}</h1>
|
||||
</div>
|
||||
<!-- Loading state while detecting auth mode -->
|
||||
<div v-if="authStore.loginStep === 'loading'" class="login-loading">
|
||||
<i class="pi pi-spin pi-spinner text-4xl text-primary"></i>
|
||||
@@ -20,7 +14,6 @@
|
||||
<!-- 2FA Step — verificare cod -->
|
||||
<div v-else-if="authStore.loginStep === '2fa'" class="login-2fa">
|
||||
<div class="twofa-header">
|
||||
<i class="pi pi-envelope text-primary twofa-icon"></i>
|
||||
<p class="twofa-info">
|
||||
Cod trimis la <strong>{{ authStore.otpMaskedEmail }}</strong>
|
||||
</p>
|
||||
@@ -176,7 +169,7 @@
|
||||
<template #footer>
|
||||
<div class="login-footer">
|
||||
<small class="text-color-secondary">
|
||||
ROA2WEB © {{ currentYear }} - Toate drepturile rezervate
|
||||
ROMFAST © {{ currentYear }}
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
@@ -214,7 +207,6 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -234,11 +226,11 @@ const props = defineProps({
|
||||
},
|
||||
appSubtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
appIcon: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
redirectPath: {
|
||||
type: String,
|
||||
@@ -468,11 +460,6 @@ const handleUnifiedInput = (event) => {
|
||||
val = val.slice(0, 8);
|
||||
}
|
||||
unifiedCode.value = val;
|
||||
|
||||
// Auto-submit la 6 cifre (OTP complet)
|
||||
if (/^\d{6}$/.test(val)) {
|
||||
handleVerifyUnified();
|
||||
}
|
||||
};
|
||||
|
||||
// Verificare unificată — detectează tipul codului și apelează handler-ul corect
|
||||
@@ -593,11 +580,15 @@ onMounted(async () => {
|
||||
// Detect auth mode and set appropriate login step (US-011)
|
||||
await props.authStore.getAuthMode();
|
||||
|
||||
// Focus identity field after auth mode is detected
|
||||
// Focus the right input based on current step
|
||||
setTimeout(() => {
|
||||
if (props.authStore.loginStep === '2fa') {
|
||||
// Restored from session — focus OTP input
|
||||
const otpInput = document.getElementById("otp-code");
|
||||
if (otpInput) otpInput.focus();
|
||||
} else {
|
||||
const identityInput = document.getElementById("identity");
|
||||
if (identityInput) {
|
||||
identityInput.focus();
|
||||
if (identityInput) identityInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
@@ -647,10 +638,6 @@ onUnmounted(() => {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.twofa-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.twofa-info {
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
|
||||
@@ -28,6 +28,11 @@ const STORAGE_KEYS = {
|
||||
AUTH_MODE: "auth_mode",
|
||||
};
|
||||
|
||||
// sessionStorage keys (tab-scoped, auto-cleared on tab close)
|
||||
const SESSION_KEYS = {
|
||||
PENDING_2FA: "pending_2fa",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returnează cheia localStorage pentru tokenul de trusted device.
|
||||
* Cheia e per-user și per-server pentru izolare corectă.
|
||||
@@ -148,6 +153,12 @@ export function createAuthStore(apiService, options = {}) {
|
||||
authMode.value = mode;
|
||||
localStorage.setItem(STORAGE_KEYS.AUTH_MODE, mode);
|
||||
|
||||
// Try to restore pending 2FA session (mobile page reload)
|
||||
if (_restore2FASession()) {
|
||||
_startResendCountdown();
|
||||
return { mode, supports_email_login };
|
||||
}
|
||||
|
||||
// Set initial login step based on auth mode
|
||||
if (mode === "single-server") {
|
||||
loginStep.value = "username";
|
||||
@@ -272,6 +283,7 @@ export function createAuthStore(apiService, options = {}) {
|
||||
// Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId
|
||||
pendingServerId.value = credentials.server_id || null;
|
||||
loginStep.value = "2fa";
|
||||
_save2FASession();
|
||||
_startResendCountdown();
|
||||
return { success: true, requires_2fa: true, masked_email: responseData.masked_email };
|
||||
}
|
||||
@@ -338,6 +350,7 @@ export function createAuthStore(apiService, options = {}) {
|
||||
is2FALoading.value = false;
|
||||
pendingServerId.value = null;
|
||||
_stopResendCountdown();
|
||||
_clear2FASession();
|
||||
|
||||
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
@@ -415,6 +428,7 @@ export function createAuthStore(apiService, options = {}) {
|
||||
is2FALoading.value = false;
|
||||
pendingServerId.value = null;
|
||||
_stopResendCountdown();
|
||||
_clear2FASession();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -467,6 +481,62 @@ export function createAuthStore(apiService, options = {}) {
|
||||
resendCountdown.value = 0;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2FA SESSION PERSISTENCE (mobile page reload fix)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const _save2FASession = () => {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEYS.PENDING_2FA, JSON.stringify({
|
||||
otpEmail: otpEmail.value,
|
||||
otpMaskedEmail: otpMaskedEmail.value,
|
||||
pendingServerId: pendingServerId.value,
|
||||
loginEmail: loginEmail.value,
|
||||
selectedServerId: selectedServerId.value,
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (e) {
|
||||
// sessionStorage may be unavailable (Safari private mode)
|
||||
}
|
||||
};
|
||||
|
||||
const _clear2FASession = () => {
|
||||
try { sessionStorage.removeItem(SESSION_KEYS.PENDING_2FA); } catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const _restore2FASession = () => {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEYS.PENDING_2FA);
|
||||
if (!raw) return false;
|
||||
|
||||
const session = JSON.parse(raw);
|
||||
|
||||
// Expire after 5 minutes (matches OTP_EXPIRY_MINUTES)
|
||||
if (Date.now() - session.savedAt > 5 * 60 * 1000) {
|
||||
_clear2FASession();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session.otpEmail || !session.otpMaskedEmail) {
|
||||
_clear2FASession();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore state
|
||||
otpEmail.value = session.otpEmail;
|
||||
otpMaskedEmail.value = session.otpMaskedEmail;
|
||||
pendingServerId.value = session.pendingServerId || null;
|
||||
loginEmail.value = session.loginEmail || "";
|
||||
selectedServerId.value = session.selectedServerId || selectedServerId.value;
|
||||
loginStep.value = "2fa";
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_clear2FASession();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2FA ACTIONS
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -526,6 +596,7 @@ export function createAuthStore(apiService, options = {}) {
|
||||
pendingServerId.value = null;
|
||||
_stopResendCountdown();
|
||||
loginStep.value = "complete";
|
||||
_clear2FASession();
|
||||
|
||||
const backupCodes = response.data.backup_codes;
|
||||
return { success: true, backup_codes: backupCodes };
|
||||
@@ -615,6 +686,7 @@ export function createAuthStore(apiService, options = {}) {
|
||||
pendingServerId.value = null;
|
||||
_stopResendCountdown();
|
||||
loginStep.value = "complete";
|
||||
_clear2FASession();
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
/* Shared Login Page Styles */
|
||||
|
||||
.login-container {
|
||||
/* Override main-content when login is displayed:
|
||||
removes header offset and turns it into the centering container */
|
||||
.main-content:has(.login-wrapper) {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-light) 0%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
padding: 1rem;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
padding: var(--space-lg) var(--space-lg) var(--space-sm);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 2rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
padding: 0 var(--space-lg) var(--space-lg);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
@@ -55,22 +49,20 @@
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary-light) !important;
|
||||
background: var(--color-primary) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--color-primary) !important;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
background: var(--color-primary-dark, var(--color-primary)) !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-error-message {
|
||||
@@ -88,9 +80,9 @@
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding: 1rem 2rem;
|
||||
background-color: var(--surface-ground);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.login-footer small {
|
||||
@@ -98,10 +90,6 @@
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
[data-theme="dark"] .login-footer {
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-error-message {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: var(--red-300);
|
||||
@@ -110,13 +98,9 @@
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@@ -124,7 +108,7 @@
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1.5rem 1rem;
|
||||
padding: var(--space-md) var(--space-md) var(--space-xs);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
@@ -132,23 +116,23 @@
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 1rem 1.5rem 1rem;
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
}
|
||||
|
||||
/* Ensure inputs are touch-friendly */
|
||||
.login-container .p-inputtext,
|
||||
.login-container .p-password input {
|
||||
.login-wrapper .p-inputtext,
|
||||
.login-wrapper .p-password input {
|
||||
min-height: 44px;
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 1rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
.login-wrapper {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -157,23 +141,19 @@
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 1rem 0.5rem;
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 0 0.5rem 1rem 0.5rem;
|
||||
padding: 0 var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 0.75rem 0.5rem;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<LoginView
|
||||
app-title="ROA2WEB"
|
||||
app-subtitle="Sistem Unificat - Rapoarte & Introduceri Date"
|
||||
app-icon="pi-chart-bar"
|
||||
app-title="ROAWEB"
|
||||
redirect-path="/reports/dashboard"
|
||||
:auth-store="authStore"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user