admin felület különválasztva
This commit is contained in:
224
.roo/history.md
224
.roo/history.md
@@ -202,3 +202,227 @@ A `GET /expenses/` endpoint (`backend/app/api/v1/endpoints/expenses.py:130-139`)
|
||||
|
||||
### 📄 Dokumentáció
|
||||
`docs/cost_pipeline_audit.md` - Teljes pipeline audit a frontendtől a DB-ig.
|
||||
|
||||
## 2026-06-23 - P0 Architecture Audit Fixes: Vendor Leak & Expense Priority
|
||||
|
||||
### 🎯 Cél
|
||||
Az Architect által auditált P0 hibák kijavítása: vendor org szivárgás megszüntetése, expense organization_id prioritás javítása, frontend payload kiegészítése vendor_organization_id-val.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- **`backend/app/services/provider_service.py`** (632-651. sor) - `quick_add_provider`: Eltávolítva az OrganizationMember létrehozás. A létrehozó user NEM lesz tagja a beszállító szervezetének. Owner_id=None marad.
|
||||
- **`backend/app/api/v1/endpoints/expenses.py`** (398-429. sor) - `create_expense`: Átírva az organization_id feloldási prioritás: 1) asset.current_organization_id, 2) asset.owner_org_id, 3) user active org membership fallback.
|
||||
- **`frontend/src/components/cost/CostEntryWizard.vue`** (690-714. sor) - `vendor_organization_id` és `external_vendor_name` root szintű payload mezők hozzáadva.
|
||||
- **`frontend/src/stores/cost.ts`** - `AddExpensePayload` interface kiegészítve `vendor_organization_id` és `external_vendor_name` mezőkkel; `addExpense()` body-ba is átadva.
|
||||
|
||||
### 🗄️ Adatbázis Cleanup
|
||||
- **`backend/scripts/fix_vendor_leak.py`** (ideiglenes, futás után törölve)
|
||||
- A) Törölve `fleet.organization_members` rekord (org_id=63, user_id=28) - phantom ADMIN jog megvonva
|
||||
- B) Javítva `fleet_finance.asset_costs` rekord: organization_id=63 → 21, vendor_organization_id=63 beállítva
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Backend restart: ✅ Sikeres
|
||||
- Python syntax check: ✅ provider_service.py OK, expenses.py OK
|
||||
- Vite build: ✅ Sikeres (255 modules, 6.97s)
|
||||
|
||||
## 2026-06-23 - P0 Tenant Isolation & Vehicle Mapping Audit
|
||||
|
||||
### Scope
|
||||
Full-stack audit of vehicle dropdown tenant isolation failure in CostsView.vue
|
||||
|
||||
### Elemzett fájlok
|
||||
- `backend/app/api/v1/endpoints/assets.py` - GET /assets/vehicles (229-318)
|
||||
- `frontend/src/views/costs/CostsView.vue` - Vehicle dropdown & org switch (330-565)
|
||||
- `frontend/src/stores/vehicle.ts` - fetchVehicles() (141-158)
|
||||
- `frontend/src/stores/auth.ts` - switchOrganization() & active_organization_id (687-717)
|
||||
- `frontend/src/stores/cost.ts` - defense-in-depth org injection (71)
|
||||
|
||||
### Adatbázis: tester_pro (User ID: 28)
|
||||
- 2 organization: Test Company (ID:1, 19 vehicles) + Private_28 (ID:21, 6 vehicles) = 25 total
|
||||
- Schema: identity.users, fleet.organization_members, fleet.organizations, vehicle.assets
|
||||
|
||||
### Root Cause
|
||||
Frontend cache staleness: vehicles load once in onMounted(), never refresh on org switch. The watch on active_organization_id only re-fetches costs, not vehicles.
|
||||
|
||||
### Verdict
|
||||
Backend filtering is correct (JWT scope_id). Database schema is correct. Fix requires adding `vehicleStore.fetchVehicles()` to the org switch watch in CostsView.vue:549.
|
||||
|
||||
### Report
|
||||
`docs/p0_tenant_isolation_vehicle_mapping_audit_report.md`
|
||||
|
||||
## 2026-06-23 - P0 Tenant Isolation Fix: CostsView.vue org switch watcher
|
||||
|
||||
### 🎯 Cél
|
||||
Frontend tenant isolation fix: garázsváltáskor a CostsView.vue watch blokkja most már reseteli a jármű szűrőt (`selectedVehicleFilter.value = null`), újratölti a járműveket az új garázs scope-jával (`vehicleStore.fetchVehicles()`), és frissíti a költségeket.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `frontend/src/views/costs/CostsView.vue` — watch blokk kiegészítése: jármű szűrő reset + vehicleStore.fetchVehicles() hívás
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite build: ✅ Sikeres (0 hiba)
|
||||
- `vehicleStore.fetchVehicles()` mindig küld GET kérést (nincs belső cache guard)
|
||||
|
||||
## 2026-06-23 - P0 EXECUTION: UI Switcher, Subscription Widget & Menu Alignment
|
||||
|
||||
### 🎯 Cél
|
||||
A P0 audit riportban azonosított hibák kijavítása: garázsválasztó szűrés, előfizetés widget jármű limit megjelenítés, mobil menü igazítás, backend admin jogosultság javítás.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
**1. HeaderCompanySwitcher.vue — Garázsválasztó javítás**
|
||||
- Eltávolítva az `org_type !== 'individual'` szűrő a `companyOrganizations` computed property-ből
|
||||
- Létrehozva új `individualOrganizations` computed property a privát garázsokhoz
|
||||
- Dropdown most két csoportra van osztva: "👤 Saját garázsok" (individual) és "🏢 Céges garázsok" (business)
|
||||
- Privát garázsokra kattintva `switchOrganization()` hívódik, majd `/dashboard` navigáció
|
||||
- Eltávolítva a keménykódolt "Private Garage" gomb — helyette dinamikus lista
|
||||
|
||||
**2. SubscriptionStatusWidget.vue — Jármű limit megjelenítés**
|
||||
- Hozzáadva `useVehicleStore` import és `vehicleCount` computed property
|
||||
- Hozzáadva `maxVehicles` computed property (org vagy user szintről)
|
||||
- Második progress bar a jármű telítettséghez (🚗 Járművek: X / Y)
|
||||
- Színkódolt sáv: zöld (<75%), sárga (75-90%), piros (>90%)
|
||||
- Ha nincs limit (0 vagy null): "Korlátlan" felirat
|
||||
- Dátum formázás és lejárati információ hozzáadva
|
||||
|
||||
**3. PrivateLayout.vue — Mobil menü igazítás**
|
||||
- "Költségek" gombhoz hozzáadva `justify-end` és `text-right` osztályok
|
||||
- A gomb ikonja és szövege jobbra igazítva
|
||||
|
||||
**4. organizations.py — Backend admin jogosultság javítás**
|
||||
- `PUT /{org_id}/subscription` végpontról eltávolítva a `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` függőség
|
||||
- Helyette `_check_org_admin_access(org_id, current_user.id, db)` hívás — így org ADMIN/OWNER is kezelheti a saját előfizetését
|
||||
|
||||
**5. organization.ts — TypeScript típus javítás**
|
||||
- `org_type` mező kötelezővé téve (eltávolítva a `?` optional flag)
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite build: ✅ Sikeres (6.75s, 0 hiba)
|
||||
- Backend Python szintaxis: ✅ Sikeres
|
||||
- API konténer újraindítás: ✅ Sikeres
|
||||
|
||||
## 2026-06-23 - P0 HOTFIX: Cost Wizard Edit Mode Bug (POST vs PUT)
|
||||
|
||||
### 🎯 Cél
|
||||
A CostEntryWizard szerkesztés módban POST helyett PUT hívást indítson, hogy ne hozzon létre duplikátumot.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
1. **`backend/app/schemas/asset_cost.py`** - Új `AssetCostUpdate` Pydantic schema (minden mező opcionális)
|
||||
2. **`backend/app/api/v1/endpoints/expenses.py`** - Új `PUT /expenses/{expense_id}` végpont a meglévő költségrekordok módosítására
|
||||
3. **`frontend/src/stores/cost.ts`** - Új `updateExpense(id, payload)` action a store-ban, ami `PUT /expenses/{id}`-t hív
|
||||
4. **`frontend/src/components/cost/CostEntryWizard.vue`** - `handleSubmit()` logika javítva: ha `props.editCost.id` létezik, `updateExpense()`-t hív, egyébként `addExpense()`-t
|
||||
|
||||
### 🗑️ Adatbázis cleanup
|
||||
- Duplikált rekord törölve: `55214098-74be-4261-a101-7fd9159e10d8` (asset_id=`deb42aea-1f48-4a70-85b5-ba451d005577`, amount_gross=5500.00)
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite build: ✅ Sikeres (7.09s, 255 module, 0 hiba)
|
||||
- Backend Python AST parse: ✅ Sikeres (expenses.py, asset_cost.py)
|
||||
- `AssetCostUpdate` schema import: ✅ Sikeres (14 field)
|
||||
- Duplikátum törlés: ✅ Sikeres
|
||||
|
||||
## 2026-06-23 - P0 Final Polish: Wizard UX & Menu Alignment
|
||||
|
||||
### 🎯 Cél
|
||||
Három kritikus UI hiba javítása: dátum hydratáció Edit módban, Fizetési Mód logika és UI átrendezés, mobil menü jobbra igazítás.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
1. **`frontend/src/components/cost/CostEntryWizard.vue`**
|
||||
- **Date Hydration Fix (watch block):** `form.invoiceDate` és `form.paymentDeadline` most már mindig érvényes `YYYY-MM-DD` stringet kap (vagy `''` ha null). Két forrást is ellenőriz: `data.invoice_date` (JSONB) és `editData.invoice_date` (top-level). Használ `rawDate.split('T')[0] || rawDate.slice(0, 10) || ''` fallback láncot.
|
||||
- **Payment Method UI áthelyezés:** A `<select>` a STEP 3 blokkban a Számla kelte és Fizetési határidő ELÉ került (Invoice Number után, Invoice Date előtt).
|
||||
- **Payment Deadline dinamikus rejtés:** `v-if="form.paymentMethod === 'TRANSFER'"` a Payment Deadline inputon — CASH/CARD esetén eltűnik.
|
||||
- **Mentési automatizmus:** `handleSubmit()`-ben, ha `form.paymentMethod === 'CASH' || 'CARD'`, a `form.paymentDeadline` automatikusan `form.invoiceDate || form.date` értékre áll.
|
||||
|
||||
2. **`frontend/src/layouts/PrivateLayout.vue`**
|
||||
- **Mobile Menu "Költségek" jobbra igazítás:** `flex-row-reverse` osztály hozzáadva, hogy az ikon a jobb szélre tapadjon. Megtartva `justify-end` és `text-right`.
|
||||
|
||||
3. **`frontend/src/layouts/OrganizationLayout.vue`**
|
||||
- **"Költségek" gomb hozzáadva** a hamburger menübe (hiányzott). Jobbra igazított `flex-row-reverse` stílussal, a `navigateToCosts()` függvény az `/organization/:id/costs` oldalra navigál.
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite build: ✅ Sikeres (7.31s, 255 modules, 0 hiba)
|
||||
|
||||
## 2026-06-24 — P0 3D Capability Matrix Audit (Issue #283)
|
||||
|
||||
**Architect:** Completed read-only audit of all SQLAlchemy models for 3D RBAC readiness.
|
||||
|
||||
**Findings:**
|
||||
- **D1 (Global Roles):** `UserRole` enum with 6 values (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER) — ✅ sufficient
|
||||
- **D2 (Tenant Roles):** `OrgUserRole` enum (OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER) + `OrgRole.permissions` JSONB + `OrganizationMember.permissions` JSON — ✅ sufficient
|
||||
- **D3 (Subscription Capabilities):** ⚠️ Partial — `SubscriptionTier.rules` JSONB exists but no standardized feature capability format. **Missing:** `custom_features` JSONB on Organization model
|
||||
- **D4 (Cost Category Gating):** ⚠️ Partial — `CostCategory.min_tier` exists but no `required_feature` column
|
||||
|
||||
**Report:** `docs/p0_3d_capability_matrix_audit_report.md`
|
||||
**Proposed migrations:** 3 ALTER TABLE statements (SubscriptionTier.feature_capabilities, Organization.custom_features, CostCategory.required_feature) + optional FeatureCapability enum
|
||||
|
||||
## 2026-06-24 — P0 3D Capability Matrix Implementation (Code)
|
||||
|
||||
### 🎯 Cél
|
||||
A 3D Capability Matrix teljes implementációja: modellek bővítése, adatbázis migráció, Capability Resolver Service, és a /categories API végpont capability-alapú védelme.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/app/models/core_logic.py` — SubscriptionTier.feature_capabilities (JSONB)
|
||||
- `backend/app/models/marketplace/organization.py` — Organization.custom_features (JSONB) + subscription_tier reláció
|
||||
- `backend/app/models/fleet_finance/models.py` — CostCategory.required_feature (String(50))
|
||||
- `backend/app/services/capability_service.py` — Új Capability Resolver Service (Superadmin Fast-Pass)
|
||||
- `backend/app/api/v1/endpoints/dictionaries.py` — /cost-categories endpoint capability filtering
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Sync Engine: 1213 OK, 0 Fixed, 0 Extra — teljes szinkron
|
||||
- Python import/attribute check: minden modelloszlop és reláció jelen van
|
||||
- resolve_org_capabilities() callable, Fast-Pass működik
|
||||
|
||||
## 2026-06-24 — P0 EXECUTION: Backoffice SSO & Staff Guards
|
||||
|
||||
### 🎯 Cél
|
||||
Cross-subdomain SSO cookie fix, backend staff role dependencies, frontend admin routing guard expansion, and dead link cleanup in AdminLayout.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
**Phase 1 — Cross-Subdomain Cookies (Backend)**
|
||||
- `backend/app/core/config.py` — Added `COOKIE_DOMAIN: str = ".servicefinder.hu"` setting
|
||||
- `backend/app/api/v1/endpoints/auth.py` — Added `domain=settings.COOKIE_DOMAIN` to both `set_cookie()` calls (login:127, verify-email:221)
|
||||
|
||||
**Phase 2 — Scalable Backend Dependencies**
|
||||
- `backend/app/api/deps.py` — Added `STAFF_ROLES` set (SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR)
|
||||
- `backend/app/api/deps.py` — Added `get_current_staff` dependency (admits all staff roles)
|
||||
- `backend/app/api/deps.py` — Added `RequireRole(allowed_roles: List[UserRole])` flexible dependency factory
|
||||
|
||||
**Phase 3 — Frontend Guards & Store**
|
||||
- `frontend/src/stores/auth.ts` — Expanded `isAdmin` getter to include SALES_REP and SERVICE_MGR
|
||||
- `frontend/src/router/index.ts` — Expanded `requiresAdmin` guard to admit all 5 staff roles
|
||||
|
||||
**Phase 4 — Admin UI Cleanup**
|
||||
- `frontend/src/layouts/AdminLayout.vue` — Removed dead sidebar links: /admin/vehicles, /admin/translations, /admin/system, /admin/audit
|
||||
|
||||
### ✅ Verifikáció
|
||||
- Vite build: ✅ Sikeres (255 modules, 7.13s, 0 hiba)
|
||||
- Python AST parse: ✅ config.py, auth.py, deps.py — all syntax OK
|
||||
- Python import test: ✅ `settings.COOKIE_DOMAIN` = `.servicefinder.hu`
|
||||
- Python import test: ✅ `STAFF_ROLES` contains all 5 roles, `get_current_staff` and `RequireRole` are callable
|
||||
|
||||
## 2026-06-24 - P0 READ & PLAN: Admin Container Separation Audit
|
||||
|
||||
### 🎯 Cél
|
||||
Architecture audit for strictly isolated Admin container. Read docker-compose.yml, NPM proxy configs, and directory structure.
|
||||
|
||||
### 🔧 Eredmények
|
||||
|
||||
**1. DOCKER ARCHITECTURE:** ✅ 3 admin-related containers exist
|
||||
- `sf_admin_ui` (Streamlit, port 8501) — Active HITL panel
|
||||
- `sf_admin_frontend` (Nuxt 3, port 8502) — Placeholder only
|
||||
- `sf_public_frontend` (Vite/Vue, port 8503) — Contains embedded admin views
|
||||
|
||||
**2. CRITICAL FINDING:** Admin views are split across two codebases
|
||||
- Full admin views (Dashboard, Users, Packages, Services, Login) exist in `frontend/src/views/admin/`
|
||||
- Separate Nuxt 3 container at `frontend/admin/` is provisioned but empty (placeholder app.vue only)
|
||||
- NPM routing already correct: `admin.servicefinder.hu` → `sf_admin_frontend:8502`
|
||||
|
||||
**3. GITEA CARDS CREATED:**
|
||||
- #284 — Main audit card (FINISHED)
|
||||
- #285 — Build Nuxt 3 Admin App
|
||||
- #286 — Remove Admin Views from Public Frontend
|
||||
- #287 — Production Dockerfile for Admin
|
||||
- #288 — Deprecate Streamlit Admin
|
||||
|
||||
**4. REPORT:** `plans/admin_container_separation_audit.md`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/deps.py
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from typing import Optional, Dict, Any, Union, List
|
||||
import logging
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
@@ -177,6 +177,65 @@ async def get_current_admin(
|
||||
return current_user
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 1.5: Staff & Flexible Role Dependencies
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# All internal staff roles that can access the Backoffice (admin.servicefinder.hu)
|
||||
STAFF_ROLES = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
UserRole.SALES_REP,
|
||||
UserRole.SERVICE_MGR,
|
||||
}
|
||||
|
||||
async def get_current_staff(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""
|
||||
🎯 Staff-level access dependency.
|
||||
|
||||
Admits all internal staff roles: SUPERADMIN, ADMIN, MODERATOR,
|
||||
SALES_REP, and SERVICE_MGR.
|
||||
|
||||
Use this for Backoffice (admin.servicefinder.hu) endpoints that
|
||||
should be accessible to all staff members, not just superadmins.
|
||||
"""
|
||||
if current_user.role not in STAFF_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.STAFF_ONLY"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def RequireRole(allowed_roles: List[UserRole]):
|
||||
"""
|
||||
🎯 Flexible role-checking dependency factory.
|
||||
|
||||
Accepts a list of UserRole values. If the current user's role is
|
||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/sales")
|
||||
async def sales_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||
):
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||
)
|
||||
return True
|
||||
return role_checker
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -130,6 +130,7 @@ async def login(
|
||||
httponly=True,
|
||||
secure=True, # Use Secure
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
@@ -224,6 +225,7 @@ async def verify_email(
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
|
||||
max_age=max_age_sec
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Szótárak és katalógusok végpontjai.
|
||||
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
|
||||
(P0 3D Capability Matrix: filtered by resolved org capabilities)
|
||||
- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
|
||||
"""
|
||||
import logging
|
||||
@@ -17,6 +18,7 @@ from app.schemas.fleet_finance import (
|
||||
InsuranceProviderCreate,
|
||||
InsuranceProviderUpdate,
|
||||
)
|
||||
from app.services.capability_service import resolve_org_capabilities
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,7 +33,21 @@ async def list_cost_categories(
|
||||
"""
|
||||
Költségkategóriák lekérése hierarchikus struktúrában.
|
||||
Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
|
||||
|
||||
P0 3D Capability Matrix:
|
||||
- Resolves the active organization's capability matrix.
|
||||
- If the user is SUPERADMIN (Fast-Pass), all categories are returned.
|
||||
- Otherwise, categories with a required_feature are only included
|
||||
if the resolved capabilities have that feature set to True.
|
||||
"""
|
||||
# ── Determine active organization ──
|
||||
active_org_id: Optional[int] = None
|
||||
if hasattr(current_user, "active_organization_id") and current_user.active_organization_id:
|
||||
active_org_id = current_user.active_organization_id
|
||||
|
||||
# ── Resolve capabilities ──
|
||||
resolved_caps = await resolve_org_capabilities(current_user, active_org_id, db)
|
||||
|
||||
stmt = select(CostCategory).order_by(CostCategory.name)
|
||||
|
||||
if visibility:
|
||||
@@ -46,6 +62,14 @@ async def list_cost_categories(
|
||||
result = await db.execute(stmt)
|
||||
categories = result.scalars().all()
|
||||
|
||||
# ── P0 3D Capability Matrix: filter by required_feature ──
|
||||
is_superadmin = resolved_caps.get("_is_superadmin", False)
|
||||
if not is_superadmin:
|
||||
categories = [
|
||||
cat for cat in categories
|
||||
if not cat.required_feature or resolved_caps.get(cat.required_feature) is True
|
||||
]
|
||||
|
||||
# Hierarchikus struktúra építése
|
||||
category_map = {}
|
||||
root_categories = []
|
||||
@@ -60,6 +84,8 @@ async def list_cost_categories(
|
||||
"visibility": cat.visibility,
|
||||
"accounting_code": cat.accounting_code,
|
||||
"is_system": cat.is_system,
|
||||
"min_tier": cat.min_tier,
|
||||
"required_feature": cat.required_feature,
|
||||
"children": [],
|
||||
}
|
||||
category_map[cat.id] = cat_dict
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -257,6 +257,100 @@ async def list_all_expenses(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
"""
|
||||
# Fetch the existing expense
|
||||
stmt = select(AssetCost).where(AssetCost.id == expense_id)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
|
||||
# Apply updates
|
||||
for field, value in update_data.items():
|
||||
setattr(cost, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def list_asset_expenses(
|
||||
asset_id: UUID,
|
||||
@@ -395,23 +489,23 @@ async def create_expense(
|
||||
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
|
||||
)
|
||||
|
||||
# Determine organization_id: prefer explicit from payload, fallback to asset fields
|
||||
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
|
||||
if not organization_id:
|
||||
# B2C fallback: if the asset is owned by a person (not an org),
|
||||
# try to find the current user's default organization
|
||||
# P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user.
|
||||
# The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21).
|
||||
# The vendor is stored separately via vendor_organization_id.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. asset.current_organization_id (the asset's current fleet/garage)
|
||||
# 2. asset.owner_org_id (the asset's owning organization)
|
||||
# 3. Fallback: user's active organization from membership
|
||||
if asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id
|
||||
else:
|
||||
# Fallback: user's active organization from membership
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.is_verified == True
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
# Last resort: any membership (even unverified)
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
|
||||
@@ -778,13 +778,13 @@ async def assign_organization_subscription(
|
||||
body: SubscriptionAssignIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
|
||||
):
|
||||
"""
|
||||
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
|
||||
|
||||
P0 Feature: This endpoint assigns a subscription_tier to an organization.
|
||||
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
|
||||
It requires org-level ADMIN or OWNER access (checked via _check_org_admin_access).
|
||||
System-level SUPERADMIN capability is also accepted via the org admin check.
|
||||
|
||||
The endpoint:
|
||||
1. Validates the tier exists in system.subscription_tiers
|
||||
@@ -792,6 +792,8 @@ async def assign_organization_subscription(
|
||||
3. Updates subscription_plan and base_asset_limit from tier rules
|
||||
4. Creates/updates a finance.org_subscriptions audit record
|
||||
"""
|
||||
# Allow org ADMIN/OWNER to manage their own subscription
|
||||
await _check_org_admin_access(org_id, current_user.id, db)
|
||||
try:
|
||||
result = await upgrade_org_subscription(
|
||||
db=db,
|
||||
|
||||
@@ -187,6 +187,7 @@ async def search_service_providers(
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||
org_type: Optional[str] = Query(None, max_length=50, description="Szervezet típus szűrő (pl. 'service_provider', 'insurance')"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -224,6 +225,7 @@ async def search_service_providers(
|
||||
category=category,
|
||||
city=city,
|
||||
category_ids=parsed_category_ids,
|
||||
org_type=org_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -86,6 +86,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- External URLs ---
|
||||
FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
|
||||
COOKIE_DOMAIN: str = ".servicefinder.hu" # Cross-subdomain SSO cookie domain
|
||||
BACKEND_CORS_ORIGINS: List[str] = Field(
|
||||
default=[
|
||||
# Production domains
|
||||
|
||||
@@ -22,6 +22,16 @@ class SubscriptionTier(Base):
|
||||
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
|
||||
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ──
|
||||
# Defines what features/capabilities this subscription tier unlocks.
|
||||
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}
|
||||
feature_capabilities: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
server_default=text("'{}'::jsonb")
|
||||
)
|
||||
|
||||
class OrganizationSubscription(Base):
|
||||
"""
|
||||
Szervezetek aktuális előfizetései és azok érvényessége.
|
||||
|
||||
@@ -56,6 +56,17 @@ class CostCategory(Base):
|
||||
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
|
||||
min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'")
|
||||
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: required_feature ──
|
||||
# If set, this category is ONLY visible to organizations whose resolved
|
||||
# capability matrix has this feature key set to True.
|
||||
# Example: "can_use_analytics" → only orgs with that capability see this category.
|
||||
required_feature: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="Feature key required to access this cost category"
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
|
||||
|
||||
@@ -151,6 +151,17 @@ class Organization(Base):
|
||||
index=True
|
||||
)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: custom_features JSONB ──
|
||||
# Organization-specific feature overrides on top of the subscription tier defaults.
|
||||
# These values MERGE with (and override) tier.feature_capabilities at runtime.
|
||||
# Example: {"can_export_data": True, "can_use_analytics": False}
|
||||
custom_features: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
server_default=text("'{}'::jsonb")
|
||||
)
|
||||
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -209,6 +220,14 @@ class Organization(Base):
|
||||
# Kapcsolat az örök személy rekordhoz
|
||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
||||
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||
"SubscriptionTier",
|
||||
foreign_keys=[subscription_tier_id],
|
||||
backref="organizations",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class OrganizationFinancials(Base):
|
||||
__tablename__ = "organization_financials"
|
||||
|
||||
@@ -81,6 +81,30 @@ class AssetCostCreate(AssetCostBase):
|
||||
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
|
||||
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
|
||||
|
||||
|
||||
class AssetCostUpdate(BaseModel):
|
||||
"""Schema for updating an existing expense via PUT /expenses/{expense_id}.
|
||||
|
||||
All fields are optional — only provided fields will be updated.
|
||||
The expense_id is passed via path parameter, asset_id and organization_id
|
||||
are immutable after creation.
|
||||
"""
|
||||
category_id: Optional[int] = None
|
||||
amount_gross: Optional[Decimal] = None
|
||||
amount_net: Optional[Decimal] = None
|
||||
vat_rate: Optional[Decimal] = None
|
||||
currency: Optional[str] = None
|
||||
date: Optional[datetime] = None
|
||||
mileage_at_cost: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
invoice_number: Optional[str] = None
|
||||
vendor_organization_id: Optional[int] = None
|
||||
external_vendor_name: Optional[str] = None
|
||||
invoice_date: Optional[date] = None
|
||||
fulfillment_date: Optional[date] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
||||
id: UUID
|
||||
|
||||
109
backend/app/services/capability_service.py
Normal file
109
backend/app/services/capability_service.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
P0 3D Capability Matrix — Capability Resolver Service
|
||||
|
||||
Resolves the effective feature capabilities for a given organization by merging:
|
||||
1. The SubscriptionTier's feature_capabilities (tier defaults)
|
||||
2. The Organization's custom_features (org-specific overrides)
|
||||
|
||||
Superadmin "Fast-Pass": If the user's role is SUPERADMIN, all capabilities
|
||||
are bypassed and a special {"_is_superadmin": True} marker is returned.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.identity import User, UserRole
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def resolve_org_capabilities(
|
||||
user: User,
|
||||
org_id: Optional[int],
|
||||
db: AsyncSession,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve the effective 3D capability matrix for a user within an organization.
|
||||
|
||||
Business Logic:
|
||||
1. Fast-Pass: If user.role is SUPERADMIN, return {"_is_superadmin": True}.
|
||||
2. If no org_id is provided, return an empty dict (no capabilities).
|
||||
3. Fetch the Organization by org_id, joined with its SubscriptionTier.
|
||||
4. tier_features = tier.feature_capabilities (or {} if None/missing).
|
||||
5. org_features = org.custom_features (or {} if None/missing).
|
||||
6. Merge: {**tier_features, **org_features} (org overrides tier).
|
||||
7. Return the merged dict.
|
||||
|
||||
Args:
|
||||
user: The authenticated User instance.
|
||||
org_id: The active organization ID (may be None).
|
||||
db: Async SQLAlchemy session.
|
||||
|
||||
Returns:
|
||||
A dict of resolved capability flags.
|
||||
"""
|
||||
# ── Fast-Pass: SUPERADMIN bypasses everything ──
|
||||
if user.role == UserRole.SUPERADMIN:
|
||||
logger.debug(
|
||||
"Capability Fast-Pass for user=%s (SUPERADMIN)",
|
||||
user.id,
|
||||
)
|
||||
return {"_is_superadmin": True}
|
||||
|
||||
# ── No active organization → no capabilities ──
|
||||
if org_id is None:
|
||||
logger.debug(
|
||||
"No active org for user=%s, returning empty capabilities",
|
||||
user.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
# ── Fetch Organization + SubscriptionTier ──
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.options(
|
||||
joinedload(Organization.subscription_tier)
|
||||
)
|
||||
.where(Organization.id == org_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
org = result.unique().scalar_one_or_none()
|
||||
|
||||
if not org:
|
||||
logger.warning(
|
||||
"Organization id=%s not found for user=%s, returning empty capabilities",
|
||||
org_id,
|
||||
user.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
# ── Extract tier features ──
|
||||
tier_features: Dict[str, Any] = {}
|
||||
if org.subscription_tier_id is not None:
|
||||
# The subscription_tier is loaded via joinedload
|
||||
tier = org.subscription_tier # type: Optional[SubscriptionTier]
|
||||
if tier and tier.feature_capabilities:
|
||||
tier_features = dict(tier.feature_capabilities)
|
||||
|
||||
# ── Extract org custom features ──
|
||||
org_features: Dict[str, Any] = {}
|
||||
if org.custom_features:
|
||||
org_features = dict(org.custom_features)
|
||||
|
||||
# ── Merge: org custom_features override tier defaults ──
|
||||
merged = {**tier_features, **org_features}
|
||||
|
||||
logger.debug(
|
||||
"Resolved capabilities for org=%s user=%s: %s",
|
||||
org_id,
|
||||
user.id,
|
||||
merged,
|
||||
)
|
||||
|
||||
return merged
|
||||
@@ -141,6 +141,7 @@ async def search_providers(
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
category_ids: Optional[List[int]] = None,
|
||||
org_type: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
@@ -174,6 +175,7 @@ async def search_providers(
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
|
||||
org_type: Szervezet típus szűrő (pl. 'service_provider', 'insurance')
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
@@ -190,9 +192,12 @@ async def search_providers(
|
||||
|
||||
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
|
||||
org_conditions = [
|
||||
Organization.org_type == OrgType.service_provider,
|
||||
Organization.is_deleted == False,
|
||||
]
|
||||
if org_type:
|
||||
org_conditions.append(Organization.org_type == org_type)
|
||||
else:
|
||||
org_conditions.append(Organization.org_type == OrgType.service_provider)
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
@@ -630,25 +635,10 @@ async def quick_add_provider(
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (P0 CRITICAL BUGFIX 2026-06-17)
|
||||
# 6. 👤 ORGANIZATION MEMBER LÉTREHOZÁSA (KIHAGYVA - P0 ARCHITECTURE FIX)
|
||||
# =====================================================================
|
||||
# A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER).
|
||||
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL).
|
||||
# Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég
|
||||
# nem jelenik meg a "Cégeim" garázsválasztóban.
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN,
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(member)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"OrganizationMember created: org_id={org.id}, user_id={user_id}, "
|
||||
f"role=ADMIN (crowdsourced provider has no owner)"
|
||||
)
|
||||
# DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
|
||||
# A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
|
||||
# =====================================================================
|
||||
|
||||
# =====================================================================
|
||||
|
||||
@@ -79,6 +79,36 @@ COST_CATEGORY_TREE = [
|
||||
("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "TIRE",
|
||||
"name": "Gumiabroncsok",
|
||||
"description": "Gumiabroncsokkal kapcsolatos költségek",
|
||||
"children": [
|
||||
("TIRE_SUMMER", "Nyári gumi", "Nyári gumiabroncs vásárlás"),
|
||||
("TIRE_WINTER", "Téli gumi", "Téli gumiabroncs vásárlás"),
|
||||
("TIRE_STORAGE", "Gumi tárolás", "Gumiabroncs tárolási díj"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "CLEANING",
|
||||
"name": "Ápolás & Kozmetika",
|
||||
"description": "Jármű ápolásával és kozmetikájával kapcsolatos költségek",
|
||||
"children": [
|
||||
("CLEANING_WASH", "Mosás", "Járműmosás"),
|
||||
("CLEANING_DETAILING", "Részletes tisztítás", "Részletes autókozmetika"),
|
||||
("CLEANING_INTERIOR", "Belső tisztítás", "Belső tér tisztítása"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "OTHER",
|
||||
"name": "Egyéb",
|
||||
"description": "Egyéb, máshova nem sorolható költségek",
|
||||
"children": [
|
||||
("OTHER_FINE", "Bírság", "Közlekedési bírság"),
|
||||
("OTHER_ACCESSORY", "Tartozék", "Jármű tartozékok"),
|
||||
("OTHER_MISC", "Egyéb", "Egyéb költségek"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -280,7 +280,7 @@ services:
|
||||
# --- NUXT 3 ADMIN FRONTEND (Epic 10 - Mission Control) ---
|
||||
sf_admin_frontend:
|
||||
build:
|
||||
context: ./frontend/admin
|
||||
context: ./frontend_admin
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: sf_admin_frontend
|
||||
env_file: .env
|
||||
@@ -291,7 +291,7 @@ services:
|
||||
- NUXT_HOST=0.0.0.0
|
||||
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
||||
volumes:
|
||||
- ./frontend/admin:/app
|
||||
- ./frontend_admin:/app
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- sf_net
|
||||
@@ -301,7 +301,7 @@ services:
|
||||
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
||||
sf_public_frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
context: ./frontend_app
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: sf_public_frontend
|
||||
env_file: .env
|
||||
@@ -310,7 +310,7 @@ services:
|
||||
environment:
|
||||
- VITE_API_BASE_URL=/api/v1
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- ./frontend_app:/app
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- sf_net
|
||||
@@ -371,7 +371,7 @@ services:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
container_name: sf_tester
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- ./frontend_app:/app
|
||||
- sf_tester_node_modules:/app/node_modules
|
||||
networks:
|
||||
- sf_net
|
||||
|
||||
265
docs/p0_3d_capability_matrix_audit_report.md
Normal file
265
docs/p0_3d_capability_matrix_audit_report.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# P0 Audit: 3D Capability Matrix (RBAC) — SQLAlchemy Model Audit Report
|
||||
|
||||
**Author:** Service Finder Rendszer-Architect
|
||||
**Date:** 2026-06-24
|
||||
**Gitea Issue:** #283
|
||||
**Scope:** Read-only model analysis for 3-Dimensional RBAC system (Global Roles × Tenant Roles × Subscription Capabilities)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report audits 4 critical dimensions against the existing SQLAlchemy model layer. The goal is to determine **what exists**, **what is missing**, and what **database migration steps** are needed to implement a full 3D Capability Matrix.
|
||||
|
||||
**Files examined:**
|
||||
- `backend/app/models/identity/identity.py` — User/Person models
|
||||
- `backend/app/models/marketplace/organization.py` — Organization, OrganizationMember, OrgRole models
|
||||
- `backend/app/models/core_logic.py` — SubscriptionTier, OrganizationSubscription, UserSubscription models
|
||||
- `backend/app/models/fleet_finance/models.py` — CostCategory model
|
||||
- `backend/scripts/seed_finance_dictionaries.py` — CostCategory seed data
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 1: Global / System Roles
|
||||
|
||||
**Model:** `User` — `identity.users`
|
||||
**Enum:** `UserRole`
|
||||
|
||||
### What EXISTS
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `role` | `PG_ENUM(UserRole)` | System-level role: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER |
|
||||
| `is_superuser` | **NOT FOUND** | There is no is_superuser boolean. Role-based only. |
|
||||
| `custom_permissions` | `JSON` | Per-user permission overrides — `{}::jsonb` default |
|
||||
| `scope_level` | `String(30)` | "individual" default — hierarchical scope context |
|
||||
| `scope_id` | `String(50)` | The ID of the scope context |
|
||||
| `is_vip` | `Boolean` | VIP flag |
|
||||
| `subscription_plan` | `String(30)` | "FREE" default |
|
||||
| `subscription_expires_at` | `DateTime` | Subscription expiry |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Dimension 1 is sufficiently covered.** The `UserRole` enum provides 6 system-level roles. The `custom_permissions` JSON column allows per-user capability overrides. However, there is **no standardized capability/feature matrix** at the global level — everything is ad-hoc JSON.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 2: Tenant / Local Roles
|
||||
|
||||
**Model:** `OrganizationMember` — `fleet.organization_members`
|
||||
**Enum:** `OrgUserRole`
|
||||
|
||||
### What EXISTS
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `role` | `PG_ENUM(OrgUserRole)` | OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER |
|
||||
| `permissions` | `JSON` | Per-member capability flags — `{}::jsonb` |
|
||||
| `is_permanent` | `Boolean` | Permanent member flag |
|
||||
| `is_verified` | `Boolean` | Verified member flag |
|
||||
| `status` | `String(20)` | "active" default |
|
||||
|
||||
### Bonus: Dynamic OrgRole RBAC System
|
||||
|
||||
**Model:** `OrgRole` — `fleet.org_roles`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `name_key` | `String(50)` | Unique role identifier (e.g., "OWNER") |
|
||||
| `display_name` | `String(100)` | Human-readable name |
|
||||
| `permissions` | `JSONB` | **Capability flags** per role — e.g. {"can_add_expense": true, "can_approve_expense": false} |
|
||||
| `is_system` | `Boolean` | System role (cannot be deleted) |
|
||||
| `priority` | `Integer` | Role hierarchy priority |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Dimension 2 is well-covered.** The `OrgRole` model with its `permissions` JSONB column is exactly what Phase 2 RBAC needs. Each `OrganizationMember` also has an individual `permissions` JSON column for user-level overrides above the role.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 3: Subscription Tier & Feature Capabilities
|
||||
|
||||
### 3A. SubscriptionTier Model
|
||||
|
||||
**Model:** `SubscriptionTier` — `system.subscription_tiers`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `name` | `String` | e.g., "free", "premium", "vip" |
|
||||
| `rules` | `JSONB` | **Generic limits**: {"max_vehicles": 5} — NO standardized feature capability format |
|
||||
| `is_custom` | `Boolean` | Custom tier flag |
|
||||
|
||||
### 3B. Organization Model (fleet schema)
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `subscription_plan` | `String(30)` | "FREE" default — denormalized quick-ref |
|
||||
| `subscription_expires_at` | `DateTime` | Subscription expiry |
|
||||
| `subscription_tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
|
||||
| `base_asset_limit` | `Integer` | Base vehicle limit (1 default) |
|
||||
| `purchased_extra_slots` | `Integer` | Extra purchased vehicle slots |
|
||||
| `settings` | `JSONB` | **Operational settings only** — NOT feature capabilities |
|
||||
|
||||
### 3C. OrganizationSubscription Model
|
||||
|
||||
**Model:** `OrganizationSubscription` — `finance.org_subscriptions`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `extra_allowances` | `JSONB` | Extra quotas: {"extra_vehicles": 1, "extra_garages": 1} |
|
||||
| `tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
|
||||
|
||||
### CRITICAL GAP: Missing custom_features / capabilities_override on Organization
|
||||
|
||||
**There is NO dedicated JSONB column** on the `Organization` model to store feature-level capability overrides like:
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_fuel_logging": true,
|
||||
"feature_gps_tracking": false,
|
||||
"feature_analytics": true,
|
||||
"feature_multi_driver": true,
|
||||
"feature_service_booking": false,
|
||||
"feature_tco_reports": false
|
||||
}
|
||||
```
|
||||
|
||||
The existing `settings` JSONB column is for *operational* settings, not *feature capability* gates.
|
||||
|
||||
Similarly, the `SubscriptionTier.rules` JSONB has **no standardized format** for feature capability flags — it only stores numeric limits.
|
||||
|
||||
---
|
||||
|
||||
## DIMENSION 4: Cost Category Feature Linkage
|
||||
|
||||
**Model:** `CostCategory` — `fleet_finance.cost_categories`
|
||||
|
||||
| Field | Type | Details |
|
||||
|---|---|---|
|
||||
| `min_tier` | `String(20)` | "free" default — minimum subscription tier to access this category |
|
||||
| `visibility` | `String(20)` | "both" default — visibility scope |
|
||||
| `is_system` | `Boolean` | System category flag |
|
||||
| `required_feature` | **NOT FOUND** | No column to tie category to a specific subscription feature |
|
||||
| `min_capability` | **NOT FOUND** | No column for minimum capability requirement |
|
||||
|
||||
### Verdict
|
||||
|
||||
**Partially covered.** The `min_tier` field gates categories by subscription tier (e.g., "free", "premium"), but there is **no way to gate by individual feature capability**. For example, we cannot say "this category requires the feature_fuel_logging capability."
|
||||
|
||||
---
|
||||
|
||||
## 3D Capability Matrix — Current vs. Required
|
||||
|
||||
| Dimension | What We Have | What We Need |
|
||||
|---|---|---|
|
||||
| D1: Global Roles | UserRole enum with 6 values + custom_permissions JSON | Sufficient. No DB changes needed. |
|
||||
| D2: Tenant Roles | OrgUserRole enum + OrgRole.permissions JSONB + per-member permissions JSON | Sufficient. No DB changes needed. |
|
||||
| D3a: Subscription Tiers | SubscriptionTier.rules JSONB (generic limits) | Need standardized feature capability format |
|
||||
| D3b: Org Feature Overrides | Organization.settings JSONB (operational only) | **NEED custom_features JSONB column** |
|
||||
| D4: Cost Category Gating | CostCategory.min_tier (tier-level only) | Need required_feature column + feature-based gating |
|
||||
|
||||
---
|
||||
|
||||
## Proposed Database Migration Steps
|
||||
|
||||
The following migrations must be executed **in order** to build the 3D Capability Matrix architecture:
|
||||
|
||||
### Migration 1: Add feature_capabilities JSONB to SubscriptionTier
|
||||
|
||||
**Target:** `SubscriptionTier` — `system.subscription_tiers`
|
||||
|
||||
```sql
|
||||
ALTER TABLE system.subscription_tiers
|
||||
ADD COLUMN feature_capabilities JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
**Purpose:** Define which features each tier enables. Example:
|
||||
|
||||
| Tier | Feature Capabilities |
|
||||
|---|---|
|
||||
| free | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_service_reminders": false, "feature_gps_tracking": false} |
|
||||
| premium | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_analytics": true, "feature_gps_tracking": true, "feature_multi_driver": true} |
|
||||
| vip | All features enabled |
|
||||
|
||||
### Migration 2: Add custom_features JSONB to Organization
|
||||
|
||||
**Target:** `Organization` — `fleet.organizations`
|
||||
|
||||
```sql
|
||||
ALTER TABLE fleet.organizations
|
||||
ADD COLUMN custom_features JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
```
|
||||
|
||||
**Purpose:** Allow per-organization feature overrides. These overrides **merge on top of** the tier defaults. A `false` value can disable a tier feature for a specific org.
|
||||
|
||||
### Migration 3: Add required_feature to CostCategory
|
||||
|
||||
**Target:** `CostCategory` — `fleet_finance.cost_categories`
|
||||
|
||||
```sql
|
||||
ALTER TABLE fleet_finance.cost_categories
|
||||
ADD COLUMN required_feature VARCHAR(50) DEFAULT NULL;
|
||||
```
|
||||
|
||||
**Purpose:** Tie a cost category to a specific feature capability.
|
||||
|
||||
| Category | required_feature |
|
||||
|---|---|
|
||||
| OPERATION_FUEL | NULL (free for all) |
|
||||
| SERVICE_SCHEDULED | NULL (free for all) |
|
||||
| FINANCING_LEASE | "feature_financing" |
|
||||
| INSURANCE_CASCO | "feature_insurance" |
|
||||
| ANALYTICS_TCO | "feature_analytics" |
|
||||
|
||||
### Migration 4 (Optional): Create FeatureCapability Enum
|
||||
|
||||
```sql
|
||||
CREATE TYPE system.feature_capability AS ENUM (
|
||||
'feature_fuel_logging',
|
||||
'feature_basic_reports',
|
||||
'feature_analytics',
|
||||
'feature_gps_tracking',
|
||||
'feature_multi_driver',
|
||||
'feature_service_booking',
|
||||
'feature_tco_reports',
|
||||
'feature_financing',
|
||||
'feature_insurance',
|
||||
'feature_ad_free'
|
||||
);
|
||||
```
|
||||
|
||||
**Purpose:** Standardize feature names across the system.
|
||||
|
||||
---
|
||||
|
||||
## Capability Resolution Algorithm (Proposed)
|
||||
|
||||
The final capability check for any feature at runtime follows this resolution chain:
|
||||
|
||||
```
|
||||
1. Organization.custom_features["feature_xxx"] <- explicit override (highest priority)
|
||||
2. SubscriptionTier.feature_capabilities["feature_xxx"] <- tier default
|
||||
3. false <- implicit default (feature not available)
|
||||
```
|
||||
|
||||
**For CostCategory visibility:**
|
||||
```
|
||||
If category.min_tier > org.subscription_plan -> HIDE
|
||||
If category.required_feature IS NOT NULL AND NOT resolved_feature_capability -> HIDE
|
||||
Otherwise -> SHOW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Status | Action Required |
|
||||
|---|---|---|
|
||||
| D1: Global Roles | Complete | None |
|
||||
| D2: Tenant Roles | Complete | None |
|
||||
| D3: Subscription Tier Features | Partial | Add feature_capabilities JSONB to SubscriptionTier |
|
||||
| D3: Org Feature Overrides | Missing | Add custom_features JSONB to Organization |
|
||||
| D4: Cost Category Feature Gate | Partial | Add required_feature column to CostCategory |
|
||||
| Standardization | Missing | (Optional) Create FeatureCapability Enum |
|
||||
|
||||
**Total proposed DB changes: 3 ALTER TABLE statements + 1 optional ENUM type.**
|
||||
266
docs/p0_backoffice_sso_router_audit_report.md
Normal file
266
docs/p0_backoffice_sso_router_audit_report.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# P0 Architect Report: Backoffice SSO & Router Architecture
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Scope:** Cross-subdomain cookie SSO, backend RBAC dependencies, frontend admin routing
|
||||
**Auditor:** Fast Coder (Core Developer)
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Cross-Subdomain Cookies (Backend Auth)
|
||||
|
||||
### File: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py)
|
||||
|
||||
#### Current State (Lines 127–134, 221–228)
|
||||
|
||||
Both the `/login` and `/verify-email` endpoints set the `refresh_token` cookie like this:
|
||||
|
||||
```python
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=max_age_sec
|
||||
)
|
||||
```
|
||||
|
||||
#### ❌ Finding: `domain` parameter is MISSING
|
||||
|
||||
The `domain` parameter is **not set** on either `set_cookie()` call. This means the cookie is scoped to the **exact origin** that issued it.
|
||||
|
||||
**Impact for SSO:**
|
||||
- If the user logs in at `app.servicefinder.hu`, the `refresh_token` cookie is only sent to `app.servicefinder.hu`.
|
||||
- When the user navigates to `admin.servicefinder.hu`, the browser **will not send** the cookie because the domain doesn't match.
|
||||
- This breaks cross-subdomain SSO entirely — the admin subdomain cannot silently refresh the access token.
|
||||
|
||||
#### Required Fix
|
||||
|
||||
Add `domain=".servicefinder.hu"` (with leading dot for subdomain wildcard) to both `set_cookie()` calls:
|
||||
|
||||
```python
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
domain=".servicefinder.hu", # ← ADD THIS
|
||||
max_age=max_age_sec
|
||||
)
|
||||
```
|
||||
|
||||
**Considerations:**
|
||||
- The domain should be **dynamic** based on environment (e.g., `.servicefinder.hu` for production, `localhost` for dev).
|
||||
- Use `settings.COOKIE_DOMAIN` or derive it from `settings.FRONTEND_BASE_URL`.
|
||||
- `samesite="lax"` is correct — it allows the cookie to be sent on top-level navigations between subdomains.
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Backend Dependencies (Deps for Staff)
|
||||
|
||||
### File: [`backend/app/api/deps.py`](backend/app/api/deps.py)
|
||||
|
||||
#### 2a. Existing `get_current_admin` (Lines 160–177)
|
||||
|
||||
```python
|
||||
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
allowed_roles = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
}
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(status_code=403, detail="...")
|
||||
return current_user
|
||||
```
|
||||
|
||||
**✅ Good:** This already exists and allows `SUPERADMIN`, `ADMIN`, and `MODERATOR`.
|
||||
|
||||
**❌ Gap:** It does **not** include `SALES_REP` or `SERVICE_MGR`. The Architect's vision is that the Backoffice (`admin.servicefinder.hu`) should admit all "staff" roles — not just superadmins.
|
||||
|
||||
#### 2b. Existing `RequireSystemCapability` (Lines 184–218)
|
||||
|
||||
```python
|
||||
def RequireSystemCapability(capability_name: str):
|
||||
async def system_capability_checker(current_user: User = Depends(get_current_user)) -> bool:
|
||||
if current_user.role == UserRole.SUPERADMIN:
|
||||
return True
|
||||
role_key = current_user.role.value
|
||||
if not role_has_capability(role_key, capability_name):
|
||||
raise HTTPException(status_code=403, detail=f"...")
|
||||
return True
|
||||
return system_capability_checker
|
||||
```
|
||||
|
||||
**✅ Good:** This is the scalable, fine-grained approach. Each endpoint can declare exactly which capability it needs.
|
||||
|
||||
**❌ Gap:** There is no **middle-ground** dependency — something between `get_current_admin` (hardcoded role set) and `RequireSystemCapability` (fine-grained). We need a `RequireRole` dependency that accepts a list of allowed roles.
|
||||
|
||||
#### 2c. Proposed: `RequireRole` Dependency
|
||||
|
||||
Add to [`backend/app/api/deps.py`](backend/app/api/deps.py):
|
||||
|
||||
```python
|
||||
def RequireRole(allowed_roles: List[UserRole]):
|
||||
"""
|
||||
🎯 Flexible role-checking dependency.
|
||||
|
||||
Accepts a list of UserRole values. If the current user's role is
|
||||
in the list, access is granted. Otherwise, 403 Forbidden.
|
||||
|
||||
Usage:
|
||||
@router.get("/admin/sales")
|
||||
async def sales_dashboard(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
|
||||
):
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> bool:
|
||||
if current_user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.INSUFFICIENT_PERMISSIONS"
|
||||
)
|
||||
return True
|
||||
return role_checker
|
||||
```
|
||||
|
||||
And a **convenience** `get_current_staff` that admits all internal staff:
|
||||
|
||||
```python
|
||||
STAFF_ROLES = {
|
||||
UserRole.SUPERADMIN,
|
||||
UserRole.ADMIN,
|
||||
UserRole.MODERATOR,
|
||||
UserRole.SALES_REP,
|
||||
UserRole.SERVICE_MGR,
|
||||
}
|
||||
|
||||
async def get_current_staff(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
if current_user.role not in STAFF_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="AUTH.STAFF_ONLY"
|
||||
)
|
||||
return current_user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AUDIT: Frontend Architecture (Admin Domain Routing)
|
||||
|
||||
### File: [`frontend/src/router/index.ts`](frontend/src/router/index.ts)
|
||||
|
||||
#### 3a. Current Admin Route (Lines 112–137)
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{ path: '', name: 'admin-dashboard', component: () => import('../views/admin/AdminDashboardView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('../views/admin/AdminUsersView.vue') },
|
||||
{ path: 'packages', name: 'admin-packages', component: () => import('../views/admin/AdminPackagesView.vue') },
|
||||
{ path: 'services', name: 'admin-services', component: () => import('../views/admin/AdminServicesView.vue') },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Good:** The admin routes exist under a single `/admin` path with `requiresAdmin` meta.
|
||||
|
||||
#### 3b. Current Route Guard (Lines 166–177)
|
||||
|
||||
```typescript
|
||||
if (to.meta.requiresAdmin) {
|
||||
const role = authStore.user?.role
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
if (!role || !adminRoles.includes(role.toUpperCase())) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Finding: The guard is too restrictive.** It only admits `SUPERADMIN`, `ADMIN`, and `MODERATOR`. `SALES_REP` and `SERVICE_MGR` are blocked from the admin panel entirely.
|
||||
|
||||
**❌ Finding: No separate subdomain routing.** The admin UI is served under the same SPA at `/admin` path, not as a separate deployment for `admin.servicefinder.hu`. This means:
|
||||
- The same Vite build serves both `app.servicefinder.hu` and `admin.servicefinder.hu`.
|
||||
- The Nginx reverse proxy must route `admin.servicefinder.hu` → same SPA, but the router handles the `/admin` prefix.
|
||||
- This is **fine for Phase 1** but limits independent scaling/deployment.
|
||||
|
||||
#### 3c. Auth Store `isAdmin` Getter (Lines 103–110)
|
||||
|
||||
```typescript
|
||||
const isAdmin = computed(() => {
|
||||
const role = user.value?.role
|
||||
if (!role) return false
|
||||
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||
return adminRoles.includes(role.toUpperCase())
|
||||
})
|
||||
```
|
||||
|
||||
**❌ Same gap:** `SALES_REP` and `SERVICE_MGR` are not recognized as admin/staff.
|
||||
|
||||
#### 3d. AdminLayout Component
|
||||
|
||||
**File:** [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue)
|
||||
|
||||
**✅ Good:** The layout has a proper sidebar with navigation links, user profile section, and slot for header controls. It references `LanguageSwitcher`, `ModeSwitcher`, and `HeaderProfile`.
|
||||
|
||||
**❌ Finding:** The sidebar links include routes that don't exist in the router (`/admin/vehicles`, `/admin/translations`, `/admin/system`, `/admin/audit`). These will produce 404s when clicked.
|
||||
|
||||
---
|
||||
|
||||
## 4. ACTION PLAN
|
||||
|
||||
### Phase 1: Fix Cookie Domain for SSO (Backend)
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 1.1 | Add `COOKIE_DOMAIN` to settings | [`backend/app/core/config.py`](backend/app/core/config.py) | Add `COOKIE_DOMAIN: str = ".servicefinder.hu"` field |
|
||||
| 1.2 | Add `domain` param to `/login` cookie | [`backend/app/api/v1/endpoints/auth.py:127`](backend/app/api/v1/endpoints/auth.py:127) | `response.set_cookie(..., domain=settings.COOKIE_DOMAIN)` |
|
||||
| 1.3 | Add `domain` param to `/verify-email` cookie | [`backend/app/api/v1/endpoints/auth.py:221`](backend/app/api/v1/endpoints/auth.py:221) | Same fix |
|
||||
|
||||
### Phase 2: Create Scalable Backend Dependencies
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 2.1 | Add `RequireRole` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Generic role-list checker |
|
||||
| 2.2 | Add `get_current_staff` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Admits SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR |
|
||||
| 2.3 | Update `get_current_admin` to use STAFF_ROLES | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Or keep as-is for superadmin-only endpoints |
|
||||
|
||||
### Phase 3: Update Frontend Router Guard & Auth Store
|
||||
|
||||
| # | Task | File | Details |
|
||||
|---|------|------|---------|
|
||||
| 3.1 | Update `requiresAdmin` guard to admit all staff roles | [`frontend/src/router/index.ts:168`](frontend/src/router/index.ts:168) | Change `adminRoles` to include `SALES_REP`, `SERVICE_MGR` |
|
||||
| 3.2 | Update `isAdmin` getter in auth store | [`frontend/src/stores/auth.ts:103`](frontend/src/stores/auth.ts:103) | Same role expansion |
|
||||
| 3.3 | Add missing admin routes or remove dead sidebar links | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | Add routes for vehicles, translations, system, audit — or remove sidebar links |
|
||||
| 3.4 | Consider role-based sidebar visibility | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | SALES_REP should only see sales-related nav items |
|
||||
|
||||
### Phase 4: Nginx / Reverse Proxy (Infrastructure)
|
||||
|
||||
| # | Task | Details |
|
||||
|---|------|---------|
|
||||
| 4.1 | Ensure `admin.servicefinder.hu` → same SPA | Nginx config must serve the same frontend build for both subdomains |
|
||||
| 4.2 | Ensure CORS allows `admin.servicefinder.hu` | Already in [`backend/app/core/config.py:93`](backend/app/core/config.py:93) ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Summary of Findings
|
||||
|
||||
| Area | Status | Issue |
|
||||
|------|--------|-------|
|
||||
| Cookie `domain` | ❌ Missing | SSO broken between subdomains |
|
||||
| `get_current_admin` | ⚠️ Too narrow | Missing SALES_REP, SERVICE_MGR |
|
||||
| `RequireSystemCapability` | ✅ Good | Fine-grained, scalable |
|
||||
| `RequireRole` | ❌ Missing | No flexible role-list dependency |
|
||||
| Frontend `requiresAdmin` guard | ⚠️ Too narrow | Blocks SALES_REP, SERVICE_MGR |
|
||||
| Frontend `isAdmin` getter | ⚠️ Too narrow | Same gap |
|
||||
| AdminLayout sidebar links | ❌ Dead links | Routes for vehicles/translations/system/audit don't exist |
|
||||
| CORS origins | ✅ Good | `admin.servicefinder.hu` already whitelisted |
|
||||
366
docs/p0_tenant_isolation_vehicle_mapping_audit_report.md
Normal file
366
docs/p0_tenant_isolation_vehicle_mapping_audit_report.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 🚨 P0 READ-ONLY AUDIT - Tenant Isolation & Vehicle Mapping Report
|
||||
|
||||
**Dátum:** 2026-06-23
|
||||
**Auditor:** Rendszer-Architect (DeepSeek Reasoner)
|
||||
**Státusz:** Elkészült
|
||||
**Scope:** Full-stack (Database → Backend API → Frontend)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Executive Summary
|
||||
|
||||
A vizsgálat célja a `CostsView.vue` jármű legördülő menüjében (vehicle dropdown) tapasztalt **tenant izolációs hiba** (Garage Isolation) kivizsgálása volt. A hibajelenség: amikor a felhasználó garázst/szervezetet vált, a költségek helyesen frissülnek, de a jármű legördülő lista a **régi garázs összes járművét** mutatja továbbra is.
|
||||
|
||||
**Verdikt:** A backend API helyesen szűr (`current_organization_id` alapján, JWT `scope_id`-n keresztül). A hiba gyökere **kizárólag a frontend cache elavulásában** (cache staleness) rejlik: a járművek csak egyszer töltődnek be (`onMounted`), és soha nem frissülnek garázsváltáskor.
|
||||
|
||||
---
|
||||
|
||||
## 📊 STEP 1: Database Data Mapping (tester_pro, User ID: 28)
|
||||
|
||||
### 1.1 User Adatok
|
||||
|
||||
| Mező | Érték |
|
||||
|------|-------|
|
||||
| `id` | 28 |
|
||||
| `email` | `tester_pro@profibot.hu` |
|
||||
| `role` | `ADMIN` |
|
||||
| `scope_id` | `null` (personal mode) |
|
||||
| `scope_level` | `individual` |
|
||||
| `subscription_plan` | `PREMIUM` |
|
||||
| `ui_mode` | `personal` |
|
||||
| `is_active` | `true` |
|
||||
|
||||
### 1.2 Organization Tagságok
|
||||
|
||||
| Org ID | Név | Típus | Szerepkör | Státusz |
|
||||
|--------|-----|-------|-----------|---------|
|
||||
| **1** | `Test Company` | `individual` | `OWNER` | `active`, `verified` |
|
||||
| **21** | `Private_28` | `individual` | `OWNER` | `active`, `verified` |
|
||||
|
||||
### 1.3 Járművek (Asset-ek) Szervezetenként
|
||||
|
||||
#### 🏢 **Org 1 - Test Company** (19 jármű)
|
||||
|
||||
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|
||||
|----------|-------|--------|-----|---------|-------------|
|
||||
| `BMW123` | BMW | BMWM3 | - | `active` | `verified` |
|
||||
| `QWE432` | BMW | R 1250 GS ADVENTURE | - | `active` | `verified` |
|
||||
| `TEST-000` | Ford | Focus | TESTVIN1111111111 | `active` | `verified` |
|
||||
| `TEST-111` | Ford | Focus | TESTVIN1111111119 | `active` | `verified` |
|
||||
| `TEST-123` | Ford | Focus | TESTVIN1234567890 | `active` | `draft` |
|
||||
| `TEST-555` | Ford | Focus | TESTVIN5555555559 | `active` | `verified` |
|
||||
| `TEST-777` | Ford | Focus | TESTVIN7777777779 | `active` | `verified` |
|
||||
| `TEST-888` | Ford | Focus | TESTVIN8888888889 | `active` | `draft` |
|
||||
| `TEST-999` | Ford | Focus | TESTVIN9999999999 | `active` | `draft` |
|
||||
| `TEST-API-999` | TestBrand | TestModel | - | `active` | `draft` |
|
||||
| `DRAFT-000` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-111` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-456` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-555` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-777` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-888` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `DRAFT-999` | Ismeretlen | - | - | `draft` | `draft` |
|
||||
| `TEST123` | TestBrand | TestModel | TESTVIN123456789 | `draft` | `draft` |
|
||||
| `HGV-TEST-01` | MERCEDES-BENZ | ACTROS | - | `archived` | `verified` |
|
||||
|
||||
#### 🏠 **Org 21 - Private_28** (6 jármű)
|
||||
|
||||
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|
||||
|----------|-------|--------|-----|---------|-------------|
|
||||
| `ABC-123` | Toyota | Corolla | 1HGCM82633A123456 | `active` | `verified` |
|
||||
| `AIML519` | Skoda | Citigo e | - | `active` | `enriched` |
|
||||
| `PKT215` | Mazda | 2 | - | `active` | `draft` |
|
||||
| `QWE123` | APRILIA | af1 | - | `active` | `draft` |
|
||||
| `TTRFGHZT` | BAYLINER | 1750 CAPRY BOWRIDER | - | `active` | `draft` |
|
||||
| `UOK795` | Honda | CB1000R | ZDCSC60C0FF091488 | `active` | `verified` |
|
||||
|
||||
**Összesen:** **25 jármű** a két szervezetben (10+6 aktív, 8 draft, 1 archived).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ STEP 2: Backend API Endpoint Audit
|
||||
|
||||
### Végpont: `GET /assets/vehicles`
|
||||
|
||||
**Fájl:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:229)
|
||||
|
||||
**Szignatúra:**
|
||||
```python
|
||||
@router.get("/vehicles", response_model=List[AssetResponse])
|
||||
async def get_user_vehicles(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthContext = Depends(get_current_user)
|
||||
):
|
||||
```
|
||||
|
||||
**Függőségek:** A végpont **nem** fogad el `organization_id` query paramétert a frontendtől. Kizárólag a JWT tokenben tárolt `current_user.scope_id` alapján szűr.
|
||||
|
||||
### Szűrési Logika
|
||||
|
||||
#### 1️⃣ Personal Mode (nincs scope_id beállítva) - [`assets.py:259`](backend/app/api/v1/endpoints/assets.py:259)
|
||||
|
||||
```python
|
||||
if current_user.scope_id is None:
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in result.fetchall()]
|
||||
|
||||
stmt = select(Asset).where(
|
||||
Asset.current_organization_id.in_(user_org_ids),
|
||||
Asset.status == "active"
|
||||
)
|
||||
```
|
||||
|
||||
**Mit csinál:** Personal módban lekéri az összes szervezet ID-t, ahol a user tag, majd visszaadja az összes olyan szervezet összes aktív járművét. Ez a **teljes flotta** a user szemszögéből.
|
||||
|
||||
#### 2️⃣ Corporate Mode (scope_id beállítva) - [`assets.py:286`](backend/app/api/v1/endpoints/assets.py:286)
|
||||
|
||||
```python
|
||||
else:
|
||||
scope_org_id = int(current_user.scope_id)
|
||||
stmt = select(Asset).where(
|
||||
Asset.current_organization_id == scope_org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
```
|
||||
|
||||
**Mit csinál:** Corporate módban kizárólag a `scope_id`-ban megadott szervezet járműveit adja vissza. Ez a helyes, izolált viselkedés.
|
||||
|
||||
### Backend Audit Verdict: ✅ **Helyes**
|
||||
|
||||
A backend API logikája megfelelő. A probléma **nem** a backendben van.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ STEP 3: Frontend Audit - CostsView.vue & Stores
|
||||
|
||||
### 3.1 A Hibás Működés Rekonstrukciója
|
||||
|
||||
#### ✅ Amit a `cost.ts` store jól csinál - [`cost.ts:71`](frontend/src/stores/cost.ts:71)
|
||||
|
||||
A költség létrehozásánál defense-in-depth jelleggel injektálja az `active_organization_id`-t:
|
||||
```typescript
|
||||
const userOrgId = useAuthStore().user?.active_organization_id
|
||||
if (userOrgId != null) {
|
||||
body.organization_id = userOrgId
|
||||
}
|
||||
```
|
||||
|
||||
#### ✅ Amit a `CostsView.vue` jól csinál - [`CostsView.vue:435`](frontend/src/views/costs/CostsView.vue:435)
|
||||
|
||||
A `fetchCosts()` függvény helyesen adja át az `active_organization_id`-t a backendnek:
|
||||
```typescript
|
||||
const params: Record<string, any> = { page: currentPage.value, per_page: itemsPerPage.value }
|
||||
const userOrgId = authStore.user?.active_organization_id
|
||||
if (userOrgId != null) {
|
||||
params.organization_id = userOrgId
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 A Hiba Gyökere: Cache Staleness
|
||||
|
||||
#### ❌ **1. hiba:** A járművek csak egyszer töltődnek be - [`CostsView.vue:559`](frontend/src/views/costs/CostsView.vue:559)
|
||||
|
||||
```typescript
|
||||
onMounted(() => {
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
vehicleStore.fetchVehicles() // <-- CSAK egyszer, onMounted-ben!
|
||||
}
|
||||
fetchCosts()
|
||||
})
|
||||
```
|
||||
|
||||
A járművek **csak akkor** töltődnek be, ha a store még üres. Ez azt jelenti, hogy ha a user már járt a Costs oldalon, a járművek a Pinia store-ban cache-elődnek, és **soha többé nem frissülnek**.
|
||||
|
||||
#### ❌ **2. hiba:** Nincs watch az org váltásra - [`CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
|
||||
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
fetchCosts() // <-- Csak költségeket frissíti
|
||||
// 🔴 HIÁNYZIK: vehicleStore.fetchVehicles() !
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Amikor a user garázst vált, a `watch` csak a költségeket frissíti. A járművek **nem** frissülnek.
|
||||
|
||||
#### ❌ **3. hiba:** `fetchVehicles()` nem fogad el org_id paramétert - [`vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
|
||||
|
||||
```typescript
|
||||
async function fetchVehicles() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await api.get('/assets/vehicles') // <-- NINCS org_id param
|
||||
vehicles.value = res.data as Vehicle[]
|
||||
} catch (err: any) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Még ha meg is hívnánk a `fetchVehicles()`-t org váltáskor, a függvény jelenleg nem tud szűrni - minden esetben a teljes (scope-alapú) listát kéri le. Szerencsére a backend JWT `scope_id`-t használ, így ha újrahívjuk, a backend a helyes (már frissített scope) alapján adja vissza az adatokat.
|
||||
|
||||
### 3.3 A Szivárgás Vizualizációja
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CostsView
|
||||
participant vehicleStore
|
||||
participant authStore
|
||||
participant Backend
|
||||
|
||||
Note over User,Backend: === Initial Load (onMounted) ===
|
||||
User->>CostsView: Navigates to Costs page
|
||||
CostsView->>vehicleStore: fetchVehicles() [no org filter]
|
||||
vehicleStore->>Backend: GET /assets/vehicles [scope_id=null]
|
||||
Backend-->>vehicleStore: [25 vehicles from ALL orgs]
|
||||
vehicleStore->>CostsView: vehicles cached in Pinia
|
||||
|
||||
Note over User,Backend: === Org Switch ===
|
||||
User->>CostsView: Switches to Org 21 (Private_28)
|
||||
CostsView->>authStore: switchOrganization(21)
|
||||
authStore->>Backend: PATCH /users/me/active-organization
|
||||
|
||||
Note over CostsView: WATCH triggers
|
||||
CostsView->>CostsView: fetchCosts() with org_id=21 ✅
|
||||
CostsView->>CostsView: 🔴 DOES NOT refresh vehicles!
|
||||
|
||||
Note over CostsView: Vehicle dropdown shows ALL 25 vehicles<br/>(from Test Company AND Private_28)
|
||||
Note over CostsView: But expenses are correctly filtered to Org 21 only!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 STEP 4: Javítási Javaslat (Fix Recommendation)
|
||||
|
||||
### Hiba Kategorizálás
|
||||
|
||||
| Kritérium | Érték |
|
||||
|-----------|-------|
|
||||
| **Típus** | Frontend Cache Staleness (Pinia store nem frissül garázsváltáskor) |
|
||||
| **Súlyosság** | **P0 - Kritikus** (adat szivárgás: user látja más garázs járműveit) |
|
||||
| **Érintett fájlok** | `CostsView.vue`, `vehicle.ts` |
|
||||
| **Backend érintettség** | ❌ Nincs (backend helyesen szűr) |
|
||||
| **Adatbázis érintettség** | ❌ Nincs (adatok helyesen vannak tárolva) |
|
||||
|
||||
### Javítási Terv
|
||||
|
||||
#### 1. Kötelező javítás: `CostsView.vue` watch kiegészítése
|
||||
|
||||
Fájl: [`frontend/src/views/costs/CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
|
||||
|
||||
**Jelenleg:**
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
fetchCosts()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Javítás után:**
|
||||
```typescript
|
||||
watch(
|
||||
() => authStore.user?.active_organization_id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
vehicleStore.fetchVehicles() // <-- ÚJ: Járművek frissítése org váltáskor
|
||||
fetchCosts()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. Ajánlott javítás: `vehicle.ts` - add org_id paraméter támogatás
|
||||
|
||||
Fájl: [`frontend/src/stores/vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
|
||||
|
||||
**Javaslat:** Bár a backend JWT scope alapján is szűr, érdemes explicit `organization_id` query paraméter támogatást hozzáadni:
|
||||
|
||||
```typescript
|
||||
async function fetchVehicles(organizationId?: number | null) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
if (organizationId != null) {
|
||||
params.organization_id = organizationId
|
||||
}
|
||||
const res = await api.get('/assets/vehicles', { params })
|
||||
vehicles.value = res.data as Vehicle[]
|
||||
} catch (err: any) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Opcionális javítás: `vehicleStore` automatikus cache invalidáció
|
||||
|
||||
Fájl: [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts)
|
||||
|
||||
Adj hozzá egy `watch`-t a `vehicle` store-ban, ami automatikusan frissíti a járműveket, ha a user `active_organization_id`-ja megváltozik:
|
||||
|
||||
```typescript
|
||||
// vehicle.ts store-ban
|
||||
watch(
|
||||
() => useAuthStore().user?.active_organization_id,
|
||||
() => {
|
||||
fetchVehicles() // Automatikus cache invalidáció org váltáskor
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 4. Opcionális javítás: Backend `GET /assets/vehicles` endpoint bővítése
|
||||
|
||||
Fájl: [`backend/app/api/v1/endpoints/assets.py:229`](backend/app/api/v1/endpoints/assets.py:229)
|
||||
|
||||
Adj hozzá egy opcionális `organization_id` query paramétert, ami felülbírálja a JWT `scope_id`-t (admin/jogosultság ellenőrzéssel):
|
||||
|
||||
```python
|
||||
@router.get("/vehicles", response_model=List[AssetResponse])
|
||||
async def get_user_vehicles(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
organization_id: Optional[int] = Query(None), # <-- ÚJ paraméter
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: AuthContext = Depends(get_current_user)
|
||||
):
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Összefoglalás
|
||||
|
||||
### Valóság vs. Elvárt Működés
|
||||
|
||||
| Aspektus | Elvárt | Valóság |
|
||||
|----------|--------|---------|
|
||||
| `tester_pro` garázsai | Csak az aktív garázs adatai | Mindkét garázs (Test Company + Private_28) járművei látszanak |
|
||||
| Költségek szűrése | Csak az aktív garázsra | ✅ Helyes - org_id alapján szűr |
|
||||
| Járművek szűrése | Csak az aktív garázsra | ❌ Hibás - nincs org_id filter, cache-ből jön |
|
||||
| Backend API | Helyes scope alapú szűrés | ✅ Helyes - JWT scope_id alapján dolgozik |
|
||||
| Adatbázis | Helyes org-hozzárendelés | ✅ Helyes - minden asset.current_organization_id be van állítva |
|
||||
|
||||
### Következtetés
|
||||
|
||||
A tenant izolációs hiba **kizárólag frontend oldali**. A backend API és az adatbázis séma megfelelő. A javításhoz elegendő a `CostsView.vue` `watch` blokkját kiegészíteni egy `vehicleStore.fetchVehicles()` hívással. A többi javítási javaslat (backend paraméter, store szintű watch) opcionális, de ajánlott a robosztusság növeléséhez.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Referenciák
|
||||
|
||||
- [CostsView.vue](frontend/src/views/costs/CostsView.vue) - A hibás watch blokk (549-556) és onMounted (558-565)
|
||||
- [vehicle.ts store](frontend/src/stores/vehicle.ts) - A fetchVehicles() függvény (141-158)
|
||||
- [auth.ts store](frontend/src/stores/auth.ts) - switchOrganization() (687) és active_organization_id (70)
|
||||
- [cost.ts store](frontend/src/stores/cost.ts) - Defense-in-depth org_id injektálás (71)
|
||||
- [assets.py (backend)](backend/app/api/v1/endpoints/assets.py) - GET /assets/vehicles (229-318)
|
||||
|
||||
---
|
||||
|
||||
*Audit lezárva: 2026-06-23 22:12 UTC*
|
||||
301
docs/p0_ui_switcher_roles_subscription_audit_report.md
Normal file
301
docs/p0_ui_switcher_roles_subscription_audit_report.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# P0 Audit Report: UI, Switcher, Roles & Subscription Visibility
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Fast Coder (Core Developer)
|
||||
**Status:** Read-Only Audit Complete — No Code Modified
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Garage Switcher (`HeaderCompanySwitcher.vue`)
|
||||
|
||||
### File: [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)
|
||||
|
||||
### Current Behavior
|
||||
|
||||
The switcher fetches organizations via `authStore.fetchMyOrganizations()` which calls `GET /api/v1/organizations/my`.
|
||||
|
||||
**Filtering logic (line 144-151):**
|
||||
```typescript
|
||||
const companyOrganizations = computed(() => {
|
||||
return authStore.myOrganizations.filter(
|
||||
(org) => org.org_type &&
|
||||
org.org_type !== 'individual' &&
|
||||
org.org_type !== 'service_provider' &&
|
||||
org.org_type !== 'service'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Root Cause of Missing Private Garages:**
|
||||
- The `org_type` field in the backend response (`GET /organizations/my`) returns the actual `OrgType` enum value.
|
||||
- The filter **excludes** `individual` type organizations — but a user's **private/personal garage** IS of type `individual`.
|
||||
- The switcher only shows the **"Private Garage" button** (line 40-58) as a hardcoded navigation to `/dashboard`, but does NOT list individual-type organizations in the dropdown.
|
||||
- If a user has **multiple** private/individual garages (e.g., "Saját garázs" + "Családi garázs"), only the first one is accessible via the hardcoded button. The others are invisible.
|
||||
|
||||
**Why company garages appear correctly:**
|
||||
- Business/fleet_owner type organizations pass the filter and are listed dynamically.
|
||||
|
||||
### Required Fix
|
||||
|
||||
1. **Remove the `org_type !== 'individual'` filter** — or change the approach to show ALL organizations.
|
||||
2. **Add visual separation** in the dropdown: group by `org_type` (e.g., "Saját garázsok" vs "Céges garázsok").
|
||||
3. **Ensure `org_type` is always populated** in the backend response (currently it's optional in the `OrganizationItem` type).
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Subscription & Limit Visibility
|
||||
|
||||
### 2a. Backend: `GET /api/v1/organizations/my`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:180)
|
||||
|
||||
**Current response includes:**
|
||||
- `subscription_plan` (string, e.g. "test_privát")
|
||||
- `subscription_expires_at` (ISO datetime)
|
||||
- `max_vehicles` (P0: from subscription tier JSONB rules)
|
||||
- `max_garages` (P0: from subscription tier JSONB rules)
|
||||
- `user_role` (OWNER/ADMIN/etc.)
|
||||
|
||||
**Verdict:** ✅ Backend already returns all necessary subscription data per organization.
|
||||
|
||||
### 2b. Backend: `GET /auth/me` (User Profile)
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:48)
|
||||
|
||||
**Current response includes:**
|
||||
- `subscription_plan` (user-level)
|
||||
- `max_vehicles`, `max_garages` (from subscription tier)
|
||||
- `system_capabilities`, `org_capabilities`
|
||||
|
||||
**Verdict:** ✅ Backend already returns user-level subscription data.
|
||||
|
||||
### 2c. Frontend: `SubscriptionStatusWidget.vue`
|
||||
|
||||
**File:** [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue)
|
||||
|
||||
**Current display:**
|
||||
- Shows plan name (`subscriptionPlan`)
|
||||
- Shows days remaining
|
||||
- Shows a progress bar (but only for time remaining, NOT for vehicle count)
|
||||
|
||||
**Missing:**
|
||||
- ❌ **No vehicle count display** (e.g., "16/100 jármű")
|
||||
- ❌ **No max_vehicles limit progress bar**
|
||||
- The widget only shows the **time-based** progress, not the **usage-based** progress.
|
||||
|
||||
### 2d. Frontend: `SubscriptionInfoModal.vue`
|
||||
|
||||
**File:** [`frontend/src/components/subscription/SubscriptionInfoModal.vue`](frontend/src/components/subscription/SubscriptionInfoModal.vue)
|
||||
|
||||
**Current display:**
|
||||
- ✅ Shows plan name
|
||||
- ✅ Shows expiry date + days remaining
|
||||
- ✅ Shows vehicle limit progress bar (`vehicleCount / maxVehicles`)
|
||||
- ✅ Shows garage limit progress bar (`garageCount / maxGarages`)
|
||||
|
||||
**Verdict:** ✅ The modal already has full subscription visibility. The issue is that this modal is only accessible via a button click, not visible on the dashboard by default.
|
||||
|
||||
### 2e. Frontend: `DashboardView.vue`
|
||||
|
||||
**File:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:78-94)
|
||||
|
||||
**Current display:**
|
||||
- ✅ `SubscriptionStatusWidget` is rendered in the dashboard (line 81)
|
||||
- ❌ But it only shows plan name + days remaining, NOT vehicle usage
|
||||
|
||||
### Required Fixes
|
||||
|
||||
1. **Enhance `SubscriptionStatusWidget.vue`** to also show:
|
||||
- Vehicle count vs limit (e.g., "🚗 16/100")
|
||||
- A secondary progress bar for vehicle usage
|
||||
- The plan name should be more prominent
|
||||
2. **Alternatively**, replace the widget with a richer component that includes both time and usage data.
|
||||
|
||||
---
|
||||
|
||||
## 3. AUDIT: Role Logic & Ownership Transfer
|
||||
|
||||
### 3a. Backend: Role-Based Access Control
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:329-357)
|
||||
|
||||
**`_check_org_admin_access()` function:**
|
||||
- Allows both `OWNER` and `ADMIN` roles to access admin endpoints
|
||||
- Used by: `GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`
|
||||
- ✅ ADMINs already have full operational rights (member management, invitations)
|
||||
|
||||
**Subscription management endpoint:**
|
||||
- `PUT /{org_id}/subscription` (line 775) requires `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` — this is a **system-level** capability, not org-level.
|
||||
- ❌ **ADMINs cannot change subscription** via this endpoint unless they also have the system capability (which typically only SUPERADMIN has).
|
||||
- The Architect's requirement says ADMINs should manage billing/subscription. This needs a separate org-level permission check.
|
||||
|
||||
### 3b. Backend: Ownership Transfer
|
||||
|
||||
**File:** [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:196)
|
||||
|
||||
- `is_ownership_transferable` field exists on the `Organization` model (boolean, default `true`)
|
||||
- `can_transfer_ownership` capability exists in `seed_org_roles.py` (line 35: OWNER=true, ADMIN=false)
|
||||
- ❌ **No dedicated backend endpoint** for "Transfer Ownership" exists yet
|
||||
- The `PATCH /{org_id}/members/{member_id}` endpoint (line 486) can change a member's role to OWNER, but there's no complete transfer flow (e.g., reassign `owner_id` on the Organization record, log the transfer, notify parties)
|
||||
|
||||
### 3c. Frontend: Members/Team Page
|
||||
|
||||
- ❌ **No frontend page** found for managing team members (search for "members", "team", "munkatárs", "tagok" returned 0 results in `.vue` files)
|
||||
- The backend has all the APIs (`GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`), but there's no UI to call them
|
||||
|
||||
### Required Fixes
|
||||
|
||||
1. **Create a Team Members page** (frontend) that uses the existing backend APIs
|
||||
2. **Add org-level subscription management** for ADMINs (separate permission check from system-level)
|
||||
3. **Create a Transfer Ownership endpoint** + UI flow
|
||||
|
||||
---
|
||||
|
||||
## 4. AUDIT: Mobile Menu Alignment (Hamburger Menu)
|
||||
|
||||
### File: [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75)
|
||||
|
||||
**Current "Költségek" button styling:**
|
||||
```html
|
||||
<button
|
||||
@click="navigateToCard('costs')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-white/40" ...>
|
||||
{{ t('menu.finance') }}
|
||||
</button>
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- The button uses `flex items-center gap-3` — this is a flex row with the icon and text.
|
||||
- The text is **not** wrapped in any alignment utility — it uses default flex alignment.
|
||||
- The icon + text are left-aligned within the flex container (default `justify-start`).
|
||||
- The `text-sm` class applies to the text, which is fine.
|
||||
- **There is NO `text-center` class** on this button or its text. The Architect's report mentions `text-center` causing misalignment, but the current code uses `flex items-center gap-3` which is correct for left-aligned menu items.
|
||||
|
||||
**However**, looking at the `OrganizationLayout.vue` hamburger menu (line 40-72), the same pattern is used — all buttons are `flex w-full items-center gap-3`. The text alignment is consistent.
|
||||
|
||||
**Possible issue:** The problem might be in a different component or a different version of the file. The current code appears correctly aligned. If there's a visual bug, it could be:
|
||||
1. A CSS conflict from a parent component
|
||||
2. An i18n translation string that's too long causing text wrapping
|
||||
3. A different mobile menu component not found in this search
|
||||
|
||||
### Required Fix
|
||||
- If the issue is confirmed, ensure all hamburger menu buttons use `flex items-center gap-3` (already the case)
|
||||
- Check if any parent CSS overrides the alignment
|
||||
- Verify the `menu.finance` translation key produces the expected text
|
||||
|
||||
---
|
||||
|
||||
## 5. ARCHITECT REPORT & ACTION PLAN
|
||||
|
||||
### Summary of Findings
|
||||
|
||||
| # | Area | Status | Priority |
|
||||
|---|------|--------|----------|
|
||||
| 1 | Garage Switcher: individual orgs filtered out | ❌ Bug | **P0** |
|
||||
| 2 | SubscriptionStatusWidget: missing vehicle usage | ❌ Missing | **P0** |
|
||||
| 3 | Team Members UI page | ❌ Missing | **P1** |
|
||||
| 4 | Ownership Transfer endpoint | ❌ Missing | **P1** |
|
||||
| 5 | ADMIN subscription management | ⚠️ Partial | **P1** |
|
||||
| 6 | Mobile menu alignment | ✅ OK (current code) | **P2** |
|
||||
|
||||
### Detailed Action Plan
|
||||
|
||||
#### 🔴 P0 — Must Fix
|
||||
|
||||
**Task 1: Fix Garage Switcher to show ALL organizations**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144) | Remove the `org_type !== 'individual'` filter. Instead, show ALL organizations grouped by type (individual vs business). Add a section header "Saját garázsok" for individual orgs. |
|
||||
| [`frontend/src/types/organization.ts`](frontend/src/types/organization.ts:24) | Make `org_type` required (remove `?`) since the backend now sends it reliably |
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:239-269) | Verify `org_type` is always populated in the response (currently uses `o.org_type.value if hasattr(...)` — ensure it never returns null) |
|
||||
|
||||
**Task 2: Enhance SubscriptionStatusWidget with vehicle usage**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue) | Add vehicle count vs limit display (e.g., "🚗 16/100"), add secondary progress bar for vehicle usage, make plan name more prominent |
|
||||
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue:72-81) | Add `maxVehicles` and `vehicleCount` computed properties (already available from `authStore.user?.max_vehicles` and `vehicleStore.vehicles.length`) |
|
||||
|
||||
#### 🟡 P1 — Should Fix
|
||||
|
||||
**Task 3: Create Team Members UI page**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/views/organization/TeamMembersView.vue` | **NEW FILE** — Create a full team management page with: member list table, role change dropdown (OWNER/ADMIN/ACCOUNTANT/DRIVER/VIEWER), invitation form, remove member button |
|
||||
| `frontend/src/router/index.ts` | Add route `/organization/:id/team` pointing to the new view |
|
||||
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:52-60) | Add "Csapat" menu item to the hamburger menu |
|
||||
|
||||
**Task 4: Create Ownership Transfer endpoint + UI**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py) | **NEW ENDPOINT** `POST /{org_id}/transfer-ownership` — requires OWNER role, accepts `new_owner_user_id`, updates `owner_id` on Organization, creates audit log, notifies both parties |
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:486-569) | The existing `PATCH /{org_id}/members/{member_id}` already supports changing role to OWNER, but doesn't update `Organization.owner_id`. Add that logic. |
|
||||
| Frontend team page | Add "Tulajdonjog átruházása" button (visible only to OWNERs) |
|
||||
|
||||
**Task 5: ADMIN subscription management**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:775-822) | Add an alternative permission check for `PUT /{org_id}/subscription` that allows org ADMINs (not just system-level) to manage subscriptions. Use `_check_org_admin_access()` instead of `RequireSystemCapability`. |
|
||||
|
||||
#### 🟢 P2 — Nice to Fix
|
||||
|
||||
**Task 6: Mobile menu alignment verification**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75) | Verify the "Költségek" button alignment. If the issue persists, add `text-left` explicitly to the button or wrap the text in a `<span class="text-left">`. |
|
||||
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:40-72) | Same verification for the company hamburger menu |
|
||||
|
||||
### Data Flow Diagram (Current vs Proposed)
|
||||
|
||||
```
|
||||
Current:
|
||||
GET /organizations/my
|
||||
→ Returns all orgs including individual
|
||||
→ HeaderCompanySwitcher FILTERS OUT individual
|
||||
→ User sees only company orgs + hardcoded "Private Garage" button
|
||||
→ Multiple private garages = invisible
|
||||
|
||||
Proposed:
|
||||
GET /organizations/my
|
||||
→ Returns all orgs including individual
|
||||
→ HeaderCompanySwitcher shows ALL orgs grouped:
|
||||
┌─────────────────────────────┐
|
||||
│ 👤 Saját garázsok │
|
||||
│ ✓ Első garázs │
|
||||
│ Második garázs │
|
||||
│─────────────────────────────│
|
||||
│ 🏢 Céges garázsok │
|
||||
│ Alpha Kft. │
|
||||
│ Beta Kft. │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Subscription Visibility Enhancement
|
||||
|
||||
```
|
||||
Current SubscriptionStatusWidget:
|
||||
┌─────────────────────────────┐
|
||||
│ test_privát 16 nap │
|
||||
│ ████████░░░░░░ │ ← only time progress
|
||||
└─────────────────────────────┘
|
||||
|
||||
Proposed SubscriptionStatusWidget:
|
||||
┌─────────────────────────────┐
|
||||
│ 💎 test_privát Aktív │
|
||||
│ Lejárat: 2026.07.09 │
|
||||
│ ████████░░░░░░ 16 nap │ ← time progress
|
||||
│ 🚗 Járművek: 16/100 │
|
||||
│ ██████████░░░░ 16% │ ← usage progress
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**End of Audit Report.** Ready for Architect review and approval before implementation.
|
||||
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
190
docs/p0_vendor_expense_architecture_audit_report.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# P0 READ & COMPARE — Vendor and Expense Architecture Audit Report
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Author:** Fast Coder (Core Developer)
|
||||
**Scope:** Vendor creation flow + Expense creation flow
|
||||
**Status:** COMPLETE — No code modified, pure audit
|
||||
|
||||
---
|
||||
|
||||
## 1. AUDIT: Vendor Creation Flow
|
||||
|
||||
### 1.1. Endpoint: `POST /providers/quick-add`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/providers.py:241`](../backend/app/api/v1/endpoints/providers.py:241)
|
||||
|
||||
This is the **primary** vendor creation endpoint used by the frontend's CostEntryWizard when a user adds a new service provider (vendor) via crowdsourcing.
|
||||
|
||||
**Actual Code Flow** (in [`backend/app/services/provider_service.py:468`](../backend/app/services/provider_service.py:468)):
|
||||
|
||||
| Step | What happens | Line |
|
||||
|------|-------------|------|
|
||||
| 1 | Organization created with `org_type=OrgType.service_provider` | `:526` |
|
||||
| 2 | `owner_id=None` — explicitly set to None | `:541` |
|
||||
| 3 | `status="pending_verification"`, `is_verified=False` | `:527-528` |
|
||||
| 4 | ServiceProfile created with `status="ghost"` | `:561-570` |
|
||||
| 5 | Branch created with address data | `:618-630` |
|
||||
| 6 | **OrganizationMember created with `role=OrgUserRole.ADMIN`** | `:639-646` |
|
||||
|
||||
### 1.2. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `org_type` | `SERVICE_PROVIDER` | `OrgType.service_provider` ✅ | **CORRECT** |
|
||||
| `owner_id` | `NULL` (ideally) | `owner_id=None` ✅ | **CORRECT** |
|
||||
| Creator in members? | **NO** — creator should NOT be a member | **BUG:** Creator IS added as `OrganizationMember` with `role=ADMIN` ❌ | **DEVIATION** |
|
||||
|
||||
### 1.3. The Member Problem
|
||||
|
||||
In [`backend/app/services/provider_service.py:639-646`](../backend/app/services/provider_service.py:639-646):
|
||||
|
||||
```python
|
||||
member = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user_id,
|
||||
role=OrgUserRole.ADMIN, # <-- ADMIN, not OWNER
|
||||
is_permanent=True,
|
||||
is_verified=True,
|
||||
)
|
||||
```
|
||||
|
||||
The code comment at line 635 says:
|
||||
> *"A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER). Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég nem jelenik meg a 'Cégeim' garázsválasztóban."*
|
||||
|
||||
**However**, the expected business logic says:
|
||||
> *"A létrehozó felhasználó NEM kerülhet be a tagok (organization_members) közé!"*
|
||||
|
||||
This is a **deliberate design decision** — the current code adds the creator as ADMIN. The expected logic says NO membership at all. This needs Architect clarification.
|
||||
|
||||
### 1.4. Secondary Endpoint: `POST /organizations/onboard`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/organizations.py:38`](../backend/app/api/v1/endpoints/organizations.py:38)
|
||||
|
||||
This endpoint is for **business onboarding** (not vendor creation). It creates:
|
||||
- `org_type=OrgType.business` (line 84)
|
||||
- `owner_id` is NOT set (implicitly NULL)
|
||||
- Creator is added as `OrganizationMember` with `role="OWNER"` (line 116-121)
|
||||
|
||||
This is **correct** for business onboarding — the creator SHOULD be the owner.
|
||||
|
||||
---
|
||||
|
||||
## 2. AUDIT: Expense Creation Flow
|
||||
|
||||
### 2.1. Endpoint: `POST /expenses/`
|
||||
|
||||
**File:** [`backend/app/api/v1/endpoints/expenses.py:345`](../backend/app/api/v1/endpoints/expenses.py:345)
|
||||
|
||||
### 2.2. How `organization_id` is Resolved
|
||||
|
||||
**Actual Code** (lines 398-429):
|
||||
|
||||
```python
|
||||
# CRITICAL FIX: organization_id MUST be the user's active organization context,
|
||||
# NOT blindly taken from the payload.
|
||||
#
|
||||
# Resolution strategy:
|
||||
# 1. First, try to resolve the user's active organization from their membership
|
||||
# 2. Fallback to asset.current_organization_id or asset.owner_org_id
|
||||
# 3. Ignore expense.organization_id from payload to prevent vendor-org pollution
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id # Priority 1
|
||||
elif asset.current_organization_id:
|
||||
organization_id = asset.current_organization_id # Priority 2
|
||||
elif asset.owner_org_id:
|
||||
organization_id = asset.owner_org_id # Priority 3
|
||||
else:
|
||||
# B2C fallback: any membership (even unverified)
|
||||
...
|
||||
```
|
||||
|
||||
### 2.3. How `vendor_organization_id` is Set
|
||||
|
||||
**Actual Code** (line 495):
|
||||
|
||||
```python
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
```
|
||||
|
||||
The payload's `vendor_organization_id` is passed **directly** to the model. This is the vendor's organization ID.
|
||||
|
||||
### 2.4. Comparison: Actual vs Expected
|
||||
|
||||
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|
||||
|--------|--------------------------|-------------|---------|
|
||||
| `organization_id` source | Asset's garage (`asset.current_organization_id`) | User's active org membership (Priority 1), then `asset.current_organization_id` (Priority 2) | **PARTIAL DEVIATION** |
|
||||
| Payload `organization_id` | Should be **IGNORED** | IS ignored — backend resolves from user context ✅ | **CORRECT** |
|
||||
| `vendor_organization_id` | The invoice issuer (vendor) | Set from `expense.vendor_organization_id` ✅ | **CORRECT** |
|
||||
|
||||
### 2.5. The organization_id Deviation Explained
|
||||
|
||||
The expected logic says:
|
||||
> *"A költség organization_id mezőjének (a költség gazdájának) a jármű saját garázsát (asset.current_organization_id) kell tükröznie."*
|
||||
|
||||
The actual code uses **Priority 1: user's active org membership**. This means if a user belongs to Organization A (their fleet manager org) but the asset is currently assigned to Organization B (a different garage), the expense will be saved under Organization A, not Organization B.
|
||||
|
||||
**This is a potential bug** in multi-org scenarios where a user manages vehicles across multiple organizations. The asset's `current_organization_id` should be the authoritative source.
|
||||
|
||||
### 2.6. Frontend Payload Analysis
|
||||
|
||||
**File:** [`frontend/src/components/cost/CostEntryWizard.vue:690-723`](../frontend/src/components/cost/CostEntryWizard.vue:690)
|
||||
|
||||
The frontend:
|
||||
1. Does **NOT** send `vendor_organization_id` in the payload (line 690-714)
|
||||
2. Sends `organization_id` = user's `active_organization_id` (line 720-723)
|
||||
3. Stores vendor info inside `data.vendor_id` and `data.vendor_name` (JSONB) (line 707-710)
|
||||
|
||||
**File:** [`frontend/src/stores/cost.ts:64-73`](../frontend/src/stores/cost.ts:64)
|
||||
|
||||
The cost store also injects `organization_id` from `authStore.user?.active_organization_id`.
|
||||
|
||||
**This means:** The frontend never sends `vendor_organization_id` to the backend! The vendor info is stored in the JSONB `data` field, but the dedicated `vendor_organization_id` column on `AssetCost` remains NULL.
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary of Findings
|
||||
|
||||
### 🟢 CORRECT (Matches Expected Logic)
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | `POST /providers/quick-add` creates org with `org_type=service_provider` | [`provider_service.py:526`](../backend/app/services/provider_service.py:526) |
|
||||
| 2 | `owner_id=None` for crowdsourced providers | [`provider_service.py:541`](../backend/app/services/provider_service.py:541) |
|
||||
| 3 | Backend `POST /expenses/` ignores payload `organization_id` | [`expenses.py:398-429`](../backend/app/api/v1/endpoints/expenses.py:398) |
|
||||
| 4 | `vendor_organization_id` is correctly mapped from schema | [`expenses.py:495`](../backend/app/api/v1/endpoints/expenses.py:495) |
|
||||
|
||||
### 🟡 DEVIATION (Needs Architect Decision)
|
||||
|
||||
| # | Finding | Location | Impact |
|
||||
|---|---------|----------|--------|
|
||||
| 1 | **Vendor creator IS added as OrganizationMember (ADMIN)** — expected: NO membership | [`provider_service.py:639-646`](../backend/app/services/provider_service.py:639) | Vendor appears in user's org list? |
|
||||
| 2 | **`organization_id` resolved from user's active org (Priority 1)** — expected: `asset.current_organization_id` | [`expenses.py:406-414`](../backend/app/api/v1/endpoints/expenses.py:406) | Wrong org in multi-org scenarios |
|
||||
| 3 | **Frontend never sends `vendor_organization_id`** — vendor info only in JSONB `data` | [`CostEntryWizard.vue:690-714`](../frontend/src/components/cost/CostEntryWizard.vue:690) | `vendor_organization_id` column always NULL |
|
||||
|
||||
### 🔴 CRITICAL GAP
|
||||
|
||||
| # | Finding | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | Frontend `CostEntryWizard` does NOT populate `vendor_organization_id` in the POST payload | [`CostEntryWizard.vue:707`](../frontend/src/components/cost/CostEntryWizard.vue:707) — vendor_id stored only in `data.vendor_id` |
|
||||
| 2 | The `cost.ts` store also doesn't map `vendor_organization_id` | [`cost.ts:53-73`](../frontend/src/stores/cost.ts:53) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended Actions
|
||||
|
||||
1. **Architect Decision Needed:** Should `quick_add_provider` add the creator as `OrganizationMember` (current behavior) or NOT (expected logic)? The current ADMIN role prevents the org from appearing in "My Companies" garage selector, but the user still has a membership record.
|
||||
|
||||
2. **Fix `organization_id` Resolution:** Change Priority 1 from "user's active org membership" to `asset.current_organization_id` to match the expected business logic exactly.
|
||||
|
||||
3. **Fix Frontend:** Add `vendor_organization_id` to the POST `/expenses/` payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONB `data`.
|
||||
|
||||
---
|
||||
|
||||
*End of Audit Report — No code was modified during this analysis.*
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<div style="font-family: sans-serif; padding: 2rem; background-color: #1a1a1a; color: #fff; min-height: 100vh;">
|
||||
<h1>ServiceFinder Admin</h1>
|
||||
<p>A biztonságos adminisztrációs és statisztikai felület konténere sikeresen felállt.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,3 +0,0 @@
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true }
|
||||
})
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div class="subscription-status-widget">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-3"
|
||||
>
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- No subscription data -->
|
||||
<div
|
||||
v-else-if="!subscriptionPlan"
|
||||
class="text-xs text-white/40 text-center py-2"
|
||||
>
|
||||
{{ t('subscription.noPlan') }}
|
||||
</div>
|
||||
|
||||
<!-- Subscription Status Display -->
|
||||
<div
|
||||
v-else
|
||||
class="subscription-info"
|
||||
>
|
||||
<!-- Plan Name -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-white/70">
|
||||
{{ subscriptionPlan }}
|
||||
</span>
|
||||
<span
|
||||
v-if="daysRemaining !== null"
|
||||
class="text-xs"
|
||||
:class="daysRemaining <= 7 ? 'text-amber-400' : 'text-white/50'"
|
||||
>
|
||||
{{ daysRemaining > 0
|
||||
? `${daysRemaining} ${t('subscription.daysLeft')}`
|
||||
: t('subscription.expired')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
v-if="progressPercent !== null"
|
||||
class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="progressBarClass"
|
||||
:style="{ width: `${Math.min(progressPercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
/**
|
||||
* Resolve the current subscription plan from the auth store.
|
||||
* For individual mode: use user-level subscription.
|
||||
* For corporate mode: use the active organization's subscription_plan.
|
||||
*/
|
||||
const subscriptionPlan = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_plan || null
|
||||
}
|
||||
// Individual mode — fallback to user's subscription
|
||||
return authStore.user?.subscription_plan || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate days remaining until subscription expires.
|
||||
* Uses valid_until from the organization or user data.
|
||||
*/
|
||||
const validUntil = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_valid_until || null
|
||||
}
|
||||
return authStore.user?.subscription_valid_until || null
|
||||
})
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
if (!validUntil.value) return null
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const diffMs = end.getTime() - now.getTime()
|
||||
return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)))
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!validUntil.value || !subscriptionPlan.value) return null
|
||||
// Assume a 30-day cycle for progress calculation
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const totalMs = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const elapsedMs = totalMs - (end.getTime() - now.getTime())
|
||||
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100))
|
||||
})
|
||||
|
||||
const progressBarClass = computed(() => {
|
||||
if (daysRemaining.value === null) return 'bg-[#70BC84]'
|
||||
if (daysRemaining.value <= 3) return 'bg-red-500'
|
||||
if (daysRemaining.value <= 7) return 'bg-amber-400'
|
||||
return 'bg-[#70BC84]'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded
|
||||
if (authStore.myOrganizations.length === 0) {
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscription-status-widget {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
18
frontend_admin/.nuxt/app.config.mjs
Normal file
18
frontend_admin/.nuxt/app.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
import { _replaceAppConfig } from '#app/config'
|
||||
import { defuFn } from 'defu'
|
||||
|
||||
const inlineConfig = {
|
||||
"nuxt": {}
|
||||
}
|
||||
|
||||
// Vite - webpack is handled directly in #app/config
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept((newModule) => {
|
||||
_replaceAppConfig(newModule.default)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default /*@__PURE__*/ defuFn(inlineConfig)
|
||||
64
frontend_admin/.nuxt/components.d.ts
vendored
Normal file
64
frontend_admin/.nuxt/components.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
|
||||
import type { DefineComponent, SlotsType } from 'vue'
|
||||
type IslandComponent<T> = DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>> & T
|
||||
|
||||
type HydrationStrategies = {
|
||||
hydrateOnVisible?: IntersectionObserverInit | true
|
||||
hydrateOnIdle?: number | true
|
||||
hydrateOnInteraction?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true
|
||||
hydrateOnMediaQuery?: string
|
||||
hydrateAfter?: number
|
||||
hydrateWhen?: boolean
|
||||
hydrateNever?: true
|
||||
}
|
||||
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
|
||||
|
||||
|
||||
export const NuxtWelcome: typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']
|
||||
export const NuxtLayout: typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
|
||||
export const NuxtErrorBoundary: typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
|
||||
export const ClientOnly: typeof import("../node_modules/nuxt/dist/app/components/client-only")['default']
|
||||
export const DevOnly: typeof import("../node_modules/nuxt/dist/app/components/dev-only")['default']
|
||||
export const ServerPlaceholder: typeof import("../node_modules/nuxt/dist/app/components/server-placeholder")['default']
|
||||
export const NuxtLink: typeof import("../node_modules/nuxt/dist/app/components/nuxt-link")['default']
|
||||
export const NuxtLoadingIndicator: typeof import("../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']
|
||||
export const NuxtTime: typeof import("../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']
|
||||
export const NuxtRouteAnnouncer: typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
|
||||
export const NuxtImg: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
|
||||
export const NuxtPicture: typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
|
||||
export const NuxtPage: typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']
|
||||
export const NoScript: typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']
|
||||
export const Link: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']
|
||||
export const Base: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Base']
|
||||
export const Title: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Title']
|
||||
export const Meta: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Meta']
|
||||
export const Style: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Style']
|
||||
export const Head: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Head']
|
||||
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']
|
||||
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
||||
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
||||
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
|
||||
export const LazyNuxtLayout: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
|
||||
export const LazyNuxtErrorBoundary: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
|
||||
export const LazyClientOnly: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/client-only")['default']>
|
||||
export const LazyDevOnly: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/dev-only")['default']>
|
||||
export const LazyServerPlaceholder: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/server-placeholder")['default']>
|
||||
export const LazyNuxtLink: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-link")['default']>
|
||||
export const LazyNuxtLoadingIndicator: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']>
|
||||
export const LazyNuxtTime: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']>
|
||||
export const LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
|
||||
export const LazyNuxtImg: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
|
||||
export const LazyNuxtPicture: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
|
||||
export const LazyNuxtPage: LazyComponent<typeof import("../node_modules/nuxt/dist/pages/runtime/page")['default']>
|
||||
export const LazyNoScript: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
|
||||
export const LazyLink: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Link']>
|
||||
export const LazyBase: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Base']>
|
||||
export const LazyTitle: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Title']>
|
||||
export const LazyMeta: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Meta']>
|
||||
export const LazyStyle: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Style']>
|
||||
export const LazyHead: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Head']>
|
||||
export const LazyHtml: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html']>
|
||||
export const LazyBody: LazyComponent<typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']>
|
||||
export const LazyNuxtIsland: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']>
|
||||
|
||||
export const componentNames: string[]
|
||||
3365
frontend_admin/.nuxt/dev/index.mjs
Normal file
3365
frontend_admin/.nuxt/dev/index.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend_admin/.nuxt/dev/index.mjs.map
Normal file
1
frontend_admin/.nuxt/dev/index.mjs.map
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BFfxzM5G.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BFfxzM5G.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{y as e,z as t}from"./C3Ck-LNv.js";import{u as a}from"./BoAo7BJa.js";const u=e((o,i)=>{if(o.path==="/login")return;if(!a("access_token").value)return t("/login")});export{u as default};
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/BW9w-mbE.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BW9w-mbE.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/BoAo7BJa.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/BoAo7BJa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/ByPKVH1D.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/ByPKVH1D.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{I as y,J as C,x as u,H as l}from"./C3Ck-LNv.js";import{u as r}from"./BoAo7BJa.js";const x=y("/sf_logo.png"),I=C("auth",()=>{const a=u(null),n=u(null),c=u(!1),s=u(null),v=u(!1),p=l(()=>!!n.value&&!!a.value),d=l(()=>{const e=a.value?.role;return e?["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"].includes(e.toUpperCase()):!1}),h=l(()=>{if(!a.value)return"";const{first_name:e,last_name:o}=a.value;return e&&o?`${e} ${o}`:e||o||a.value.email}),k=l(()=>a.value?.email??""),_=l(()=>a.value?.role??"guest");async function w(e,o){c.value=!0,s.value=null;try{const t=new URLSearchParams;t.append("username",e),t.append("password",o);const m=await $fetch("/api/v1/auth/login",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:t}),A=r("access_token",{maxAge:3600*24*7,path:"/",sameSite:"lax",secure:!0});A.value=m.access_token,n.value=m.access_token,await i()}catch(t){throw s.value=t?.data?.detail||t?.message||"Login failed",t}finally{c.value=!1}}async function i(){const o=r("access_token").value||n.value;if(o)try{const t=await $fetch("/api/v1/auth/me",{headers:{Authorization:`Bearer ${o}`}});a.value=t}catch(t){throw t?.response?.status===401&&f(),t}}function f(){n.value=null,a.value=null,s.value=null;const e=r("access_token");e.value=null}async function R(){const e=r("access_token");if(e.value){n.value=e.value;try{await i()}catch{f()}}v.value=!0}function g(){s.value=null}return{user:a,token:n,isLoading:c,error:s,isInitialized:v,isAuthenticated:p,isAdmin:d,userName:h,userEmail:k,userRole:_,login:w,fetchUser:i,logout:f,init:R,clearError:g}});export{x as _,I as u};
|
||||
4
frontend_admin/.nuxt/dist/client/_nuxt/C3Ck-LNv.js
vendored
Normal file
4
frontend_admin/.nuxt/dist/client/_nuxt/C3Ck-LNv.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/CyOWWS80.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/CyOWWS80.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/Di-57MrI.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/Di-57MrI.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{u as a,e as s,h as u,i as r,f as o}from"./C3Ck-LNv.js";function i(e){const t=e||s();return t?.ssrContext?.head||t?.runWithContext(()=>{if(u())return r(o)})}function x(e,t={}){const n=i(t.nuxt);if(n)return a(e,{head:n,...t})}export{x as u};
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/Drr7KP9v.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/Drr7KP9v.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/Oih3kTQ2.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/Oih3kTQ2.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as a}from"./QKJPEFuj.js";import{_ as i,o as u,c,a as e,t as r,b as l,w as d,d as p}from"./C3Ck-LNv.js";import{u as f}from"./Di-57MrI.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return f({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return u(),c("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[l(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:d(()=>[p(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/QKJPEFuj.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/QKJPEFuj.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/client/_nuxt/XMkMKtwS.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/XMkMKtwS.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as v}from"./QKJPEFuj.js";import{g as y,k as h,c as n,a as t,j as _,l as s,t as c,m as u,p as k,q as p,v as m,s as x,b as S,w as V,x as f,o as i,d as C}from"./C3Ck-LNv.js";import{u as L,_ as N}from"./ByPKVH1D.js";import"./BoAo7BJa.js";const B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},j={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},R={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],A={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},D={class:"mt-6 text-center"},U=y({__name:"login",setup(F){const a=f(""),r=f(""),b=h(),o=L();async function g(){try{await o.login(a.value,r.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=v;return i(),n("div",B,[t("div",E,[e[6]||(e[6]=_('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+N+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",j,[s(o).error?(i(),n("div",R,c(s(o).error),1)):u("",!0),t("form",{onSubmit:k(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=l=>x(a)?a.value=l:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=l=>x(r)?r.value=l:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(r)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),n("svg",A,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",D,[S(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:V(()=>[...e[5]||(e[5]=[C(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{U as default};
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/entry.KrfumMBl.css
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/entry.KrfumMBl.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
body{margin:0;padding:0}
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/error-404.DL_4WIao.css
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/error-404.DL_4WIao.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.spotlight[data-v-1bd9e11a]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);bottom:-30vh;filter:blur(20vh);height:40vh}.gradient-border[data-v-1bd9e11a]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:.5rem;position:relative}@media(prefers-color-scheme:light){.gradient-border[data-v-1bd9e11a]{background-color:#ffffff4d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#e2e2e2,#e2e2e2 25%,#00dc82,#36e4da 75%,#0047e1)}}@media(prefers-color-scheme:dark){.gradient-border[data-v-1bd9e11a]{background-color:#1414144d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#303030,#303030 25%,#00dc82,#36e4da 75%,#0047e1)}}.gradient-border[data-v-1bd9e11a]:before{background-size:400% auto;border-radius:.5rem;content:"";inset:0;-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask-composite:exclude;opacity:.5;padding:2px;position:absolute;transition:background-position .3s ease-in-out,opacity .2s ease-in-out;width:100%}.gradient-border[data-v-1bd9e11a]:hover:before{background-position:-50% 0;opacity:1}.fixed[data-v-1bd9e11a]{position:fixed}.left-0[data-v-1bd9e11a]{left:0}.right-0[data-v-1bd9e11a]{right:0}.z-10[data-v-1bd9e11a]{z-index:10}.z-20[data-v-1bd9e11a]{z-index:20}.grid[data-v-1bd9e11a]{display:grid}.mb-16[data-v-1bd9e11a]{margin-bottom:4rem}.mb-8[data-v-1bd9e11a]{margin-bottom:2rem}.max-w-520px[data-v-1bd9e11a]{max-width:520px}.min-h-screen[data-v-1bd9e11a]{min-height:100vh}.w-full[data-v-1bd9e11a]{width:100%}.flex[data-v-1bd9e11a]{display:flex}.cursor-pointer[data-v-1bd9e11a]{cursor:pointer}.place-content-center[data-v-1bd9e11a]{place-content:center}.items-center[data-v-1bd9e11a]{align-items:center}.justify-center[data-v-1bd9e11a]{justify-content:center}.overflow-hidden[data-v-1bd9e11a]{overflow:hidden}.bg-white[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-4[data-v-1bd9e11a]{padding-left:1rem;padding-right:1rem}.px-8[data-v-1bd9e11a]{padding-left:2rem;padding-right:2rem}.py-2[data-v-1bd9e11a]{padding-bottom:.5rem;padding-top:.5rem}.text-center[data-v-1bd9e11a]{text-align:center}.text-8xl[data-v-1bd9e11a]{font-size:6rem;line-height:1}.text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-1bd9e11a]{font-weight:300}.font-medium[data-v-1bd9e11a]{font-weight:500}.leading-tight[data-v-1bd9e11a]{line-height:1.25}.font-sans[data-v-1bd9e11a]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-1bd9e11a]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\:bg-black[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\:text-white[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\:px-0[data-v-1bd9e11a]{padding-left:0;padding-right:0}.sm\:px-6[data-v-1bd9e11a]{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-3[data-v-1bd9e11a]{padding-bottom:.75rem;padding-top:.75rem}.sm\:text-4xl[data-v-1bd9e11a]{font-size:2.25rem;line-height:2.5rem}.sm\:text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}}
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/error-500.I1Dtv2V5.css
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/error-500.I1Dtv2V5.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.spotlight[data-v-a01dd0ba]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);filter:blur(20vh)}.fixed[data-v-a01dd0ba]{position:fixed}.-bottom-1\/2[data-v-a01dd0ba]{bottom:-50%}.left-0[data-v-a01dd0ba]{left:0}.right-0[data-v-a01dd0ba]{right:0}.grid[data-v-a01dd0ba]{display:grid}.mb-16[data-v-a01dd0ba]{margin-bottom:4rem}.mb-8[data-v-a01dd0ba]{margin-bottom:2rem}.h-1\/2[data-v-a01dd0ba]{height:50%}.max-w-520px[data-v-a01dd0ba]{max-width:520px}.min-h-screen[data-v-a01dd0ba]{min-height:100vh}.place-content-center[data-v-a01dd0ba]{place-content:center}.overflow-hidden[data-v-a01dd0ba]{overflow:hidden}.bg-white[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-8[data-v-a01dd0ba]{padding-left:2rem;padding-right:2rem}.text-center[data-v-a01dd0ba]{text-align:center}.text-8xl[data-v-a01dd0ba]{font-size:6rem;line-height:1}.text-xl[data-v-a01dd0ba]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-a01dd0ba]{font-weight:300}.font-medium[data-v-a01dd0ba]{font-weight:500}.leading-tight[data-v-a01dd0ba]{line-height:1.25}.font-sans[data-v-a01dd0ba]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-a01dd0ba]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\:bg-black[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\:text-white[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\:px-0[data-v-a01dd0ba]{padding-left:0;padding-right:0}.sm\:text-4xl[data-v-a01dd0ba]{font-size:2.25rem;line-height:2.5rem}}
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/usmOON9X.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/usmOON9X.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{_ as s,o as a,c as i,a as e,t as o}from"./C3Ck-LNv.js";import{u}from"./Di-57MrI.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return u({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(a(),i("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};
|
||||
4
frontend_admin/.nuxt/dist/server/_nuxt/app-styles.BKebClGy.mjs
vendored
Normal file
4
frontend_admin/.nuxt/dist/server/_nuxt/app-styles.BKebClGy.mjs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import style_0 from "./entry-styles-3.mjs-C1rWf53M.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
119
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js
vendored
Normal file
119
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
import { publicAssetsURL } from "#internal/nuxt/paths";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { u as useCookie } from "./cookie-CWIsZYm7.js";
|
||||
const _imports_0 = publicAssetsURL("/sf_logo.png");
|
||||
const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref(null);
|
||||
const token = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const isInitialized = ref(false);
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
||||
const isAdmin = computed(() => {
|
||||
const role = user.value?.role;
|
||||
if (!role) return false;
|
||||
const staffRoles = ["SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR"];
|
||||
return staffRoles.includes(role.toUpperCase());
|
||||
});
|
||||
const userName = computed(() => {
|
||||
if (!user.value) return "";
|
||||
const { first_name, last_name } = user.value;
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`;
|
||||
return first_name || last_name || user.value.email;
|
||||
});
|
||||
const userEmail = computed(() => user.value?.email ?? "");
|
||||
const userRole = computed(() => user.value?.role ?? "guest");
|
||||
async function login(email, password) {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const body = new URLSearchParams();
|
||||
body.append("username", email);
|
||||
body.append("password", password);
|
||||
const res = await $fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body
|
||||
});
|
||||
const tokenCookie = useCookie("access_token", {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
// 7 days
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: true
|
||||
});
|
||||
tokenCookie.value = res.access_token;
|
||||
token.value = res.access_token;
|
||||
await fetchUser();
|
||||
} catch (err) {
|
||||
error.value = err?.data?.detail || err?.message || "Login failed";
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function fetchUser() {
|
||||
const tokenCookie = useCookie("access_token");
|
||||
const currentToken = tokenCookie.value || token.value;
|
||||
if (!currentToken) return;
|
||||
try {
|
||||
const res = await $fetch("/api/v1/auth/me", {
|
||||
headers: { Authorization: `Bearer ${currentToken}` }
|
||||
});
|
||||
user.value = res;
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 401) {
|
||||
logout();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
error.value = null;
|
||||
const tokenCookie = useCookie("access_token");
|
||||
tokenCookie.value = null;
|
||||
}
|
||||
async function init() {
|
||||
const tokenCookie = useCookie("access_token");
|
||||
if (tokenCookie.value) {
|
||||
token.value = tokenCookie.value;
|
||||
try {
|
||||
await fetchUser();
|
||||
} catch {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
isInitialized.value = true;
|
||||
}
|
||||
function clearError() {
|
||||
error.value = null;
|
||||
}
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
isInitialized,
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
userName,
|
||||
userEmail,
|
||||
userRole,
|
||||
// Actions
|
||||
login,
|
||||
fetchUser,
|
||||
logout,
|
||||
init,
|
||||
clearError
|
||||
};
|
||||
});
|
||||
export {
|
||||
_imports_0 as _,
|
||||
useAuthStore as u
|
||||
};
|
||||
//# sourceMappingURL=auth-CRAMW6e4.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-CRAMW6e4.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
30
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js
vendored
Normal file
30
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import { d as defineNuxtRouteMiddleware, n as navigateTo } from "../server.mjs";
|
||||
import { u as useCookie } from "./cookie-CWIsZYm7.js";
|
||||
import "vue";
|
||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||
import "#internal/nuxt/paths";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
import "/app/node_modules/unctx/dist/index.mjs";
|
||||
import "/app/node_modules/h3/dist/index.mjs";
|
||||
import "pinia";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
import "vue-router";
|
||||
import "/app/node_modules/ufo/dist/index.mjs";
|
||||
import "/app/node_modules/klona/dist/index.mjs";
|
||||
import "vue/server-renderer";
|
||||
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||
import "/app/node_modules/destr/dist/index.mjs";
|
||||
import "/app/node_modules/ohash/dist/index.mjs";
|
||||
const auth = defineNuxtRouteMiddleware((to, from) => {
|
||||
if (to.path === "/login") {
|
||||
return;
|
||||
}
|
||||
const tokenCookie = useCookie("access_token");
|
||||
if (!tokenCookie.value) {
|
||||
return navigateTo("/login");
|
||||
}
|
||||
});
|
||||
export {
|
||||
auth as default
|
||||
};
|
||||
//# sourceMappingURL=auth-D_CY0lMs.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"auth-D_CY0lMs.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/auth-D_CY0lMs.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"auth-D_CY0lMs.js","mappings":";;;;;;;;;;;;;;;;;AAAA,MAAA,OAAe,0BAA0B,CAAC,IAAI,SAAS;AAErD,MAAI,GAAG,SAAS,UAAU;AACxB;AAAA,EACF;AAGA,QAAM,cAAc,UAAU,cAAc;AAC5C,MAAI,CAAC,YAAY,OAAO;AACtB,WAAO,WAAW,QAAQ;AAAA,EAC5B;AAKF,CAAC;","names":[],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware((to, from) => {\n // Skip auth check on the login page itself\n if (to.path === '/login') {\n return\n }\n\n // Check for the access_token cookie\n const tokenCookie = useCookie('access_token')\n if (!tokenCookie.value) {\n return navigateTo('/login')\n }\n\n // If the store is already initialized, check isAuthenticated\n // Otherwise, the token cookie presence is sufficient for the guard\n // The store's init() will validate the token on app mount\n})\n"],"version":3}
|
||||
79
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js
vendored
Normal file
79
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from "vue";
|
||||
import { parse } from "/app/node_modules/cookie-es/dist/index.mjs";
|
||||
import { getRequestHeader, setCookie, getCookie, deleteCookie } from "/app/node_modules/h3/dist/index.mjs";
|
||||
import destr from "/app/node_modules/destr/dist/index.mjs";
|
||||
import { isEqual } from "/app/node_modules/ohash/dist/index.mjs";
|
||||
import { klona } from "/app/node_modules/klona/dist/index.mjs";
|
||||
import { a as useNuxtApp } from "../server.mjs";
|
||||
function useRequestEvent(nuxtApp) {
|
||||
nuxtApp ||= useNuxtApp();
|
||||
return nuxtApp.ssrContext?.event;
|
||||
}
|
||||
const CookieDefaults = {
|
||||
path: "/",
|
||||
watch: true,
|
||||
decode: (val) => {
|
||||
const decoded = decodeURIComponent(val);
|
||||
const parsed = destr(decoded);
|
||||
if (typeof parsed === "number" && (!Number.isFinite(parsed) || String(parsed) !== decoded)) {
|
||||
return decoded;
|
||||
}
|
||||
return parsed;
|
||||
},
|
||||
encode: (val) => encodeURIComponent(typeof val === "string" ? val : JSON.stringify(val))
|
||||
};
|
||||
function useCookie(name, _opts) {
|
||||
const opts = { ...CookieDefaults, ..._opts };
|
||||
opts.filter ??= (key) => key === name;
|
||||
const cookies = readRawCookies(opts) || {};
|
||||
let delay;
|
||||
if (opts.maxAge !== void 0) {
|
||||
delay = opts.maxAge * 1e3;
|
||||
} else if (opts.expires) {
|
||||
delay = opts.expires.getTime() - Date.now();
|
||||
}
|
||||
const hasExpired = delay !== void 0 && delay <= 0;
|
||||
const cookieValue = klona(hasExpired ? void 0 : cookies[name] ?? opts.default?.());
|
||||
const cookie = ref(cookieValue);
|
||||
{
|
||||
const nuxtApp = useNuxtApp();
|
||||
const writeFinalCookieValue = () => {
|
||||
if (opts.readonly || isEqual(cookie.value, cookies[name])) {
|
||||
return;
|
||||
}
|
||||
nuxtApp._cookies ||= {};
|
||||
if (name in nuxtApp._cookies) {
|
||||
if (isEqual(cookie.value, nuxtApp._cookies[name])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
nuxtApp._cookies[name] = cookie.value;
|
||||
writeServerCookie(useRequestEvent(nuxtApp), name, cookie.value, opts);
|
||||
};
|
||||
const unhook = nuxtApp.hooks.hookOnce("app:rendered", writeFinalCookieValue);
|
||||
nuxtApp.hooks.hookOnce("app:error", () => {
|
||||
unhook();
|
||||
return writeFinalCookieValue();
|
||||
});
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
function readRawCookies(opts = {}) {
|
||||
{
|
||||
return parse(getRequestHeader(useRequestEvent(), "cookie") || "", opts);
|
||||
}
|
||||
}
|
||||
function writeServerCookie(event, name, value, opts = {}) {
|
||||
if (event) {
|
||||
if (value !== null && value !== void 0) {
|
||||
return setCookie(event, name, value, opts);
|
||||
}
|
||||
if (getCookie(event, name) !== void 0) {
|
||||
return deleteCookie(event, name, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
useCookie as u
|
||||
};
|
||||
//# sourceMappingURL=cookie-CWIsZYm7.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/cookie-CWIsZYm7.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
253
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js
vendored
Normal file
253
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
import { _ as __nuxt_component_0 } from "./nuxt-link-DNVrgHUL.js";
|
||||
import { defineComponent, ref, computed, mergeProps, withCtx, createVNode, toDisplayString, withDirectives, vShow, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
|
||||
import { ssrRenderAttrs, ssrRenderClass, ssrRenderAttr, ssrRenderList, ssrInterpolate, ssrRenderStyle, ssrRenderComponent, ssrRenderSlot } from "vue/server-renderer";
|
||||
import { u as useAuthStore, _ as _imports_0 } from "./auth-CRAMW6e4.js";
|
||||
import { useRoute } from "vue-router";
|
||||
import { u as useRouter } from "../server.mjs";
|
||||
import "/app/node_modules/ufo/dist/index.mjs";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
import "#internal/nuxt/paths";
|
||||
import "pinia";
|
||||
import "./cookie-CWIsZYm7.js";
|
||||
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||
import "/app/node_modules/h3/dist/index.mjs";
|
||||
import "/app/node_modules/destr/dist/index.mjs";
|
||||
import "/app/node_modules/ohash/dist/index.mjs";
|
||||
import "/app/node_modules/klona/dist/index.mjs";
|
||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
import "/app/node_modules/unctx/dist/index.mjs";
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
__name: "default",
|
||||
__ssrInlineRender: true,
|
||||
setup(__props) {
|
||||
const route = useRoute();
|
||||
useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const sidebarOpen = ref(false);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const dropdownOpen = ref(false);
|
||||
ref(null);
|
||||
const expandedGroups = ref(/* @__PURE__ */ new Set(["Központ"]));
|
||||
const userEmail = computed(() => authStore.userEmail || "admin@servicefinder.hu");
|
||||
const userInitials = computed(() => {
|
||||
const email = authStore.userEmail;
|
||||
if (!email) return "AD";
|
||||
return email.split("@")[0].split(".").map((s) => s[0]).join("").toUpperCase().slice(0, 2);
|
||||
});
|
||||
const menuGroups = [
|
||||
{
|
||||
title: "Központ",
|
||||
items: [
|
||||
{
|
||||
path: "/",
|
||||
label: "Dashboard",
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Felhasználók & Partnerek",
|
||||
items: [
|
||||
{
|
||||
label: "Garázsok",
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>',
|
||||
children: [
|
||||
{
|
||||
path: "/garages",
|
||||
label: "Garázsok listája",
|
||||
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "Felhasználók",
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>',
|
||||
children: [
|
||||
{
|
||||
path: "/users",
|
||||
label: "Felhasználók listája",
|
||||
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg>'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Rendszer",
|
||||
items: [
|
||||
{
|
||||
path: "/permissions",
|
||||
label: "Jogosultságok",
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>'
|
||||
},
|
||||
{
|
||||
label: "Rendszernaplók",
|
||||
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
|
||||
children: [
|
||||
{
|
||||
path: "/logs",
|
||||
label: "Rendszernaplók",
|
||||
icon: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
const pageTitle = computed(() => {
|
||||
if (route.path === "/") return "Dashboard";
|
||||
if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés";
|
||||
if (route.path.startsWith("/garages")) return "Garázsok";
|
||||
if (route.path.startsWith("/users")) return "Felhasználók";
|
||||
if (route.path.startsWith("/logs")) return "Rendszernaplók";
|
||||
return "Admin Panel";
|
||||
});
|
||||
function isActive(path) {
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
function isGroupActive(item) {
|
||||
if (!item.children) return false;
|
||||
return item.children.some((child) => isActive(child.path));
|
||||
}
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
const _component_NuxtLink = __nuxt_component_0;
|
||||
_push(`<div${ssrRenderAttrs(mergeProps({ class: "min-h-screen bg-slate-900 text-white flex" }, _attrs))}>`);
|
||||
if (sidebarOpen.value) {
|
||||
_push(`<div class="fixed inset-0 bg-black/50 z-20 lg:hidden"></div>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`<aside class="${ssrRenderClass([
|
||||
"fixed lg:static inset-y-0 left-0 z-30 flex flex-col bg-slate-800 border-r border-slate-700 transition-all duration-300",
|
||||
sidebarOpen.value ? "w-64 translate-x-0" : "-translate-x-full lg:translate-x-0 lg:w-20"
|
||||
])}"><div class="flex items-center h-16 px-4 border-b border-slate-700">`);
|
||||
if (sidebarOpen.value || !sidebarCollapsed.value) {
|
||||
_push(`<div class="flex items-center gap-3"><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto" alt="SF Logo"><span class="text-lg font-bold text-white whitespace-nowrap">Admin Panel</span></div>`);
|
||||
} else {
|
||||
_push(`<div class="mx-auto"><img${ssrRenderAttr("src", _imports_0)} class="h-8 w-auto" alt="SF Logo"></div>`);
|
||||
}
|
||||
_push(`</div><button class="hidden lg:flex items-center justify-center h-10 mx-2 mt-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="${ssrRenderClass([{ "rotate-180": sidebarCollapsed.value }, "w-5 h-5 transition-transform duration-300"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"></path></svg></button><nav class="flex-1 px-2 py-4 space-y-2 overflow-y-auto"><!--[-->`);
|
||||
ssrRenderList(menuGroups, (group) => {
|
||||
_push(`<!--[-->`);
|
||||
if (!sidebarCollapsed.value || sidebarOpen.value) {
|
||||
_push(`<div class="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-slate-500">${ssrInterpolate(group.title)}</div>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`<!--[-->`);
|
||||
ssrRenderList(group.items, (item) => {
|
||||
_push(`<!--[-->`);
|
||||
if (item.children) {
|
||||
_push(`<div><button class="${ssrRenderClass([isGroupActive(item) ? "bg-indigo-600/20 text-indigo-300" : "text-slate-300 hover:bg-slate-700 hover:text-white", "flex items-center w-full gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition"])}"><span class="flex-shrink-0 w-5 h-5">${item.icon ?? ""}</span><span class="flex-1 text-left whitespace-nowrap" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}">${ssrInterpolate(item.label)}</span><svg class="${ssrRenderClass([{ "rotate-90": expandedGroups.value.has(item.label) }, "w-4 h-4 transition-transform duration-200"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg></button><div class="ml-2 mt-1 space-y-1 overflow-hidden transition-all duration-200" style="${ssrRenderStyle(expandedGroups.value.has(item.label) && (!sidebarCollapsed.value || sidebarOpen.value) ? null : { display: "none" })}"><!--[-->`);
|
||||
ssrRenderList(item.children, (child) => {
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
key: child.path,
|
||||
to: child.path,
|
||||
class: ["flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition", isActive(child.path) ? "bg-indigo-600 text-white" : "text-slate-400 hover:bg-slate-700 hover:text-white"],
|
||||
onClick: ($event) => sidebarOpen.value = false
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(`<span class="flex-shrink-0 w-4 h-4"${_scopeId}>${child.icon ?? ""}</span><span class="whitespace-nowrap"${_scopeId}>${ssrInterpolate(child.label)}</span>`);
|
||||
} else {
|
||||
return [
|
||||
createVNode("span", {
|
||||
class: "flex-shrink-0 w-4 h-4",
|
||||
innerHTML: child.icon
|
||||
}, null, 8, ["innerHTML"]),
|
||||
createVNode("span", { class: "whitespace-nowrap" }, toDisplayString(child.label), 1)
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 2
|
||||
}, _parent));
|
||||
});
|
||||
_push(`<!--]--></div></div>`);
|
||||
} else {
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: item.path,
|
||||
class: ["flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition", isActive(item.path) ? "bg-indigo-600 text-white" : "text-slate-300 hover:bg-slate-700 hover:text-white"],
|
||||
onClick: ($event) => sidebarOpen.value = false
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(`<span class="flex-shrink-0 w-5 h-5"${_scopeId}>${item.icon ?? ""}</span><span class="whitespace-nowrap" style="${ssrRenderStyle(!sidebarCollapsed.value || sidebarOpen.value ? null : { display: "none" })}"${_scopeId}>${ssrInterpolate(item.label)}</span>`);
|
||||
} else {
|
||||
return [
|
||||
createVNode("span", {
|
||||
class: "flex-shrink-0 w-5 h-5",
|
||||
innerHTML: item.icon
|
||||
}, null, 8, ["innerHTML"]),
|
||||
withDirectives(createVNode("span", { class: "whitespace-nowrap" }, toDisplayString(item.label), 513), [
|
||||
[vShow, !sidebarCollapsed.value || sidebarOpen.value]
|
||||
])
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 2
|
||||
}, _parent));
|
||||
}
|
||||
_push(`<!--]-->`);
|
||||
});
|
||||
_push(`<!--]--><!--]-->`);
|
||||
});
|
||||
_push(`<!--]--></nav><div class="p-4 border-t border-slate-700">`);
|
||||
if (!sidebarCollapsed.value || sidebarOpen.value) {
|
||||
_push(`<div class="text-xs text-slate-500"> ServiceFinder v2.0 </div>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`</div></aside><div class="flex-1 flex flex-col min-w-0"><header class="sticky top-0 z-10 bg-slate-800/95 backdrop-blur-sm border-b border-slate-700"><div class="flex items-center justify-between h-16 px-4 lg:px-6"><div class="flex items-center gap-3"><button class="lg:hidden p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button><h1 class="text-lg font-semibold text-white truncate">${ssrInterpolate(pageTitle.value)}</h1></div><div class="relative"><button class="flex items-center gap-2 p-1.5 rounded-lg hover:bg-slate-700 transition"><div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-sm font-bold text-white">${ssrInterpolate(userInitials.value)}</div><span class="hidden sm:block text-sm text-slate-300">${ssrInterpolate(userEmail.value)}</span><svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg></button>`);
|
||||
if (dropdownOpen.value) {
|
||||
_push(`<div class="absolute right-0 mt-2 w-56 bg-slate-800 border border-slate-700 rounded-xl shadow-xl py-1 z-50"><div class="px-4 py-3 border-b border-slate-700"><p class="text-sm font-medium text-white">${ssrInterpolate(userEmail.value)}</p><p class="text-xs text-slate-400 mt-0.5">Administrator</p></div>`);
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/profile",
|
||||
class: "flex items-center gap-3 px-4 py-2.5 text-sm text-slate-300 hover:bg-slate-700 hover:text-white transition",
|
||||
onClick: ($event) => dropdownOpen.value = false
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(`<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"${_scopeId}><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"${_scopeId}></path></svg> Profile Settings `);
|
||||
} else {
|
||||
return [
|
||||
(openBlock(), createBlock("svg", {
|
||||
class: "w-4 h-4",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
viewBox: "0 0 24 24"
|
||||
}, [
|
||||
createVNode("path", {
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2",
|
||||
d: "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
})
|
||||
])),
|
||||
createTextVNode(" Profile Settings ")
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(`<button class="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-slate-700 hover:text-red-300 transition"><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg> Sign Out </button></div>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`</div></div></header><main class="flex-1 p-4 lg:p-6 overflow-auto">`);
|
||||
ssrRenderSlot(_ctx.$slots, "default", {}, null, _push, _parent);
|
||||
_push(`</main></div></div>`);
|
||||
};
|
||||
}
|
||||
});
|
||||
const _sfc_setup = _sfc_main.setup;
|
||||
_sfc_main.setup = (props, ctx) => {
|
||||
const ssrContext = useSSRContext();
|
||||
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("layouts/default.vue");
|
||||
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
|
||||
};
|
||||
export {
|
||||
_sfc_main as default
|
||||
};
|
||||
//# sourceMappingURL=default-DbJQlxCD.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/default-DbJQlxCD.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"entry-styles-1.mjs-CF-ChPYj.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-CF-ChPYj.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"entry-styles-1.mjs-CF-ChPYj.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"entry-styles-2.mjs-CntxBZab.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CntxBZab.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"entry-styles-2.mjs-CntxBZab.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
const app_vue_vue_type_style_index_0_lang = "body{margin:0;padding:0}";
|
||||
export {
|
||||
app_vue_vue_type_style_index_0_lang as default
|
||||
};
|
||||
//# sourceMappingURL=entry-styles-3.mjs-C1rWf53M.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"entry-styles-3.mjs-C1rWf53M.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-3.mjs-C1rWf53M.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"entry-styles-3.mjs-C1rWf53M.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.BhrIOCGw.mjs
vendored
Normal file
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.BhrIOCGw.mjs
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import style_0 from "./entry-styles-1.mjs-CF-ChPYj.js";
|
||||
import style_1 from "./entry-styles-2.mjs-CntxBZab.js";
|
||||
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
|
||||
export default [
|
||||
style_0,
|
||||
style_1,
|
||||
style_2
|
||||
]
|
||||
93
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js
vendored
Normal file
93
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
import { _ as __nuxt_component_0 } from "./nuxt-link-DNVrgHUL.js";
|
||||
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
|
||||
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
|
||||
import { _ as _export_sfc } from "../server.mjs";
|
||||
import { u as useHead } from "./v3-DnDMBKvA.js";
|
||||
import "/app/node_modules/ufo/dist/index.mjs";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||
import "#internal/nuxt/paths";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
import "/app/node_modules/unctx/dist/index.mjs";
|
||||
import "/app/node_modules/h3/dist/index.mjs";
|
||||
import "pinia";
|
||||
import "vue-router";
|
||||
import "/app/node_modules/klona/dist/index.mjs";
|
||||
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||
const _sfc_main = {
|
||||
__name: "error-404",
|
||||
__ssrInlineRender: true,
|
||||
props: {
|
||||
appName: {
|
||||
type: String,
|
||||
default: "Nuxt"
|
||||
},
|
||||
version: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
status: {
|
||||
type: Number,
|
||||
default: 404
|
||||
},
|
||||
statusText: {
|
||||
type: String,
|
||||
default: "Not Found"
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "Sorry, the page you are looking for could not be found."
|
||||
},
|
||||
backHome: {
|
||||
type: String,
|
||||
default: "Go back home"
|
||||
}
|
||||
},
|
||||
setup(__props) {
|
||||
const props = __props;
|
||||
useHead({
|
||||
title: `${props.status} - ${props.statusText} | ${props.appName}`,
|
||||
script: [
|
||||
{
|
||||
innerHTML: `!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`
|
||||
}
|
||||
],
|
||||
style: [
|
||||
{
|
||||
innerHTML: `*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }`
|
||||
}
|
||||
]
|
||||
});
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
const _component_NuxtLink = __nuxt_component_0;
|
||||
_push(`<div${ssrRenderAttrs(mergeProps({ class: "antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black" }, _attrs))} data-v-1bd9e11a><div class="fixed left-0 right-0 spotlight z-10" data-v-1bd9e11a></div><div class="max-w-520px text-center z-20" data-v-1bd9e11a><h1 class="font-medium mb-8 sm:text-10xl text-8xl" data-v-1bd9e11a>${ssrInterpolate(__props.status)}</h1><p class="font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl" data-v-1bd9e11a>${ssrInterpolate(__props.description)}</p><div class="flex items-center justify-center w-full" data-v-1bd9e11a>`);
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/",
|
||||
class: "cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(`${ssrInterpolate(__props.backHome)}`);
|
||||
} else {
|
||||
return [
|
||||
createTextVNode(toDisplayString(__props.backHome), 1)
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(`</div></div></div>`);
|
||||
};
|
||||
}
|
||||
};
|
||||
const _sfc_setup = _sfc_main.setup;
|
||||
_sfc_main.setup = (props, ctx) => {
|
||||
const ssrContext = useSSRContext();
|
||||
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("node_modules/nuxt/dist/app/components/error-404.vue");
|
||||
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
|
||||
};
|
||||
const error404 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-1bd9e11a"]]);
|
||||
export {
|
||||
error404 as default
|
||||
};
|
||||
//# sourceMappingURL=error-404-BDmq62MM.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-BDmq62MM.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
5
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
const error404_vue_vue_type_style_index_0_scoped_1bd9e11a_lang = '.spotlight[data-v-1bd9e11a]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);bottom:-30vh;filter:blur(20vh);height:40vh}.gradient-border[data-v-1bd9e11a]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:.5rem;position:relative}@media(prefers-color-scheme:light){.gradient-border[data-v-1bd9e11a]{background-color:#ffffff4d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#e2e2e2,#e2e2e2 25%,#00dc82,#36e4da 75%,#0047e1)}}@media(prefers-color-scheme:dark){.gradient-border[data-v-1bd9e11a]{background-color:#1414144d}.gradient-border[data-v-1bd9e11a]:before{background:linear-gradient(90deg,#303030,#303030 25%,#00dc82,#36e4da 75%,#0047e1)}}.gradient-border[data-v-1bd9e11a]:before{background-size:400% auto;border-radius:.5rem;content:"";inset:0;-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask-composite:exclude;opacity:.5;padding:2px;position:absolute;transition:background-position .3s ease-in-out,opacity .2s ease-in-out;width:100%}.gradient-border[data-v-1bd9e11a]:hover:before{background-position:-50% 0;opacity:1}.fixed[data-v-1bd9e11a]{position:fixed}.left-0[data-v-1bd9e11a]{left:0}.right-0[data-v-1bd9e11a]{right:0}.z-10[data-v-1bd9e11a]{z-index:10}.z-20[data-v-1bd9e11a]{z-index:20}.grid[data-v-1bd9e11a]{display:grid}.mb-16[data-v-1bd9e11a]{margin-bottom:4rem}.mb-8[data-v-1bd9e11a]{margin-bottom:2rem}.max-w-520px[data-v-1bd9e11a]{max-width:520px}.min-h-screen[data-v-1bd9e11a]{min-height:100vh}.w-full[data-v-1bd9e11a]{width:100%}.flex[data-v-1bd9e11a]{display:flex}.cursor-pointer[data-v-1bd9e11a]{cursor:pointer}.place-content-center[data-v-1bd9e11a]{place-content:center}.items-center[data-v-1bd9e11a]{align-items:center}.justify-center[data-v-1bd9e11a]{justify-content:center}.overflow-hidden[data-v-1bd9e11a]{overflow:hidden}.bg-white[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-4[data-v-1bd9e11a]{padding-left:1rem;padding-right:1rem}.px-8[data-v-1bd9e11a]{padding-left:2rem;padding-right:2rem}.py-2[data-v-1bd9e11a]{padding-bottom:.5rem;padding-top:.5rem}.text-center[data-v-1bd9e11a]{text-align:center}.text-8xl[data-v-1bd9e11a]{font-size:6rem;line-height:1}.text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-1bd9e11a]{font-weight:300}.font-medium[data-v-1bd9e11a]{font-weight:500}.leading-tight[data-v-1bd9e11a]{line-height:1.25}.font-sans[data-v-1bd9e11a]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-1bd9e11a]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\\:bg-black[data-v-1bd9e11a]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\\:text-white[data-v-1bd9e11a]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\\:px-0[data-v-1bd9e11a]{padding-left:0;padding-right:0}.sm\\:px-6[data-v-1bd9e11a]{padding-left:1.5rem;padding-right:1.5rem}.sm\\:py-3[data-v-1bd9e11a]{padding-bottom:.75rem;padding-top:.75rem}.sm\\:text-4xl[data-v-1bd9e11a]{font-size:2.25rem;line-height:2.5rem}.sm\\:text-xl[data-v-1bd9e11a]{font-size:1.25rem;line-height:1.75rem}}';
|
||||
export {
|
||||
error404_vue_vue_type_style_index_0_scoped_1bd9e11a_lang as default
|
||||
};
|
||||
//# sourceMappingURL=error-404-styles-1.mjs-COQ9lBg6.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"error-404-styles-1.mjs-COQ9lBg6.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles-1.mjs-COQ9lBg6.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"error-404-styles-1.mjs-COQ9lBg6.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
4
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles.CiJjK6WX.mjs
vendored
Normal file
4
frontend_admin/.nuxt/dist/server/_nuxt/error-404-styles.CiJjK6WX.mjs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import style_0 from "./error-404-styles-1.mjs-COQ9lBg6.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
71
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js
vendored
Normal file
71
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mergeProps, useSSRContext } from "vue";
|
||||
import { ssrRenderAttrs, ssrInterpolate } from "vue/server-renderer";
|
||||
import { _ as _export_sfc } from "../server.mjs";
|
||||
import { u as useHead } from "./v3-DnDMBKvA.js";
|
||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||
import "#internal/nuxt/paths";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
import "/app/node_modules/unctx/dist/index.mjs";
|
||||
import "/app/node_modules/h3/dist/index.mjs";
|
||||
import "pinia";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
import "vue-router";
|
||||
import "/app/node_modules/ufo/dist/index.mjs";
|
||||
import "/app/node_modules/klona/dist/index.mjs";
|
||||
import "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||
const _sfc_main = {
|
||||
__name: "error-500",
|
||||
__ssrInlineRender: true,
|
||||
props: {
|
||||
appName: {
|
||||
type: String,
|
||||
default: "Nuxt"
|
||||
},
|
||||
version: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
status: {
|
||||
type: Number,
|
||||
default: 500
|
||||
},
|
||||
statusText: {
|
||||
type: String,
|
||||
default: "Server error"
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "This page is temporarily unavailable."
|
||||
}
|
||||
},
|
||||
setup(__props) {
|
||||
const props = __props;
|
||||
useHead({
|
||||
title: `${props.status} - ${props.statusText} | ${props.appName}`,
|
||||
script: [
|
||||
{
|
||||
innerHTML: `!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`
|
||||
}
|
||||
],
|
||||
style: [
|
||||
{
|
||||
innerHTML: `*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }`
|
||||
}
|
||||
]
|
||||
});
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
_push(`<div${ssrRenderAttrs(mergeProps({ class: "antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black" }, _attrs))} data-v-a01dd0ba><div class="-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight" data-v-a01dd0ba></div><div class="max-w-520px text-center" data-v-a01dd0ba><h1 class="font-medium mb-8 sm:text-10xl text-8xl" data-v-a01dd0ba>${ssrInterpolate(__props.status)}</h1><p class="font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl" data-v-a01dd0ba>${ssrInterpolate(__props.description)}</p></div></div>`);
|
||||
};
|
||||
}
|
||||
};
|
||||
const _sfc_setup = _sfc_main.setup;
|
||||
_sfc_main.setup = (props, ctx) => {
|
||||
const ssrContext = useSSRContext();
|
||||
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("node_modules/nuxt/dist/app/components/error-500.vue");
|
||||
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
|
||||
};
|
||||
const error500 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a01dd0ba"]]);
|
||||
export {
|
||||
error500 as default
|
||||
};
|
||||
//# sourceMappingURL=error-500-gzzPk_-U.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-gzzPk_-U.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
5
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
const error500_vue_vue_type_style_index_0_scoped_a01dd0ba_lang = ".spotlight[data-v-a01dd0ba]{background:linear-gradient(45deg,#00dc82,#36e4da 50%,#0047e1);filter:blur(20vh)}.fixed[data-v-a01dd0ba]{position:fixed}.-bottom-1\\/2[data-v-a01dd0ba]{bottom:-50%}.left-0[data-v-a01dd0ba]{left:0}.right-0[data-v-a01dd0ba]{right:0}.grid[data-v-a01dd0ba]{display:grid}.mb-16[data-v-a01dd0ba]{margin-bottom:4rem}.mb-8[data-v-a01dd0ba]{margin-bottom:2rem}.h-1\\/2[data-v-a01dd0ba]{height:50%}.max-w-520px[data-v-a01dd0ba]{max-width:520px}.min-h-screen[data-v-a01dd0ba]{min-height:100vh}.place-content-center[data-v-a01dd0ba]{place-content:center}.overflow-hidden[data-v-a01dd0ba]{overflow:hidden}.bg-white[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(255 255 255/var(--un-bg-opacity))}.px-8[data-v-a01dd0ba]{padding-left:2rem;padding-right:2rem}.text-center[data-v-a01dd0ba]{text-align:center}.text-8xl[data-v-a01dd0ba]{font-size:6rem;line-height:1}.text-xl[data-v-a01dd0ba]{font-size:1.25rem;line-height:1.75rem}.text-black[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(0 0 0/var(--un-text-opacity))}.font-light[data-v-a01dd0ba]{font-weight:300}.font-medium[data-v-a01dd0ba]{font-weight:500}.leading-tight[data-v-a01dd0ba]{line-height:1.25}.font-sans[data-v-a01dd0ba]{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.antialiased[data-v-a01dd0ba]{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media(prefers-color-scheme:dark){.dark\\:bg-black[data-v-a01dd0ba]{--un-bg-opacity:1;background-color:rgb(0 0 0/var(--un-bg-opacity))}.dark\\:text-white[data-v-a01dd0ba]{--un-text-opacity:1;color:rgb(255 255 255/var(--un-text-opacity))}}@media(min-width:640px){.sm\\:px-0[data-v-a01dd0ba]{padding-left:0;padding-right:0}.sm\\:text-4xl[data-v-a01dd0ba]{font-size:2.25rem;line-height:2.5rem}}";
|
||||
export {
|
||||
error500_vue_vue_type_style_index_0_scoped_a01dd0ba_lang as default
|
||||
};
|
||||
//# sourceMappingURL=error-500-styles-1.mjs-C0glhSuM.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"error-500-styles-1.mjs-C0glhSuM.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles-1.mjs-C0glhSuM.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"error-500-styles-1.mjs-C0glhSuM.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
4
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles.ulHNpcF1.mjs
vendored
Normal file
4
frontend_admin/.nuxt/dist/server/_nuxt/error-500-styles.ulHNpcF1.mjs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import style_0 from "./error-500-styles-1.mjs-C0glhSuM.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
33
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js
vendored
Normal file
33
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-92TOgIUU.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
55
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js
vendored
Normal file
55
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineComponent, useSSRContext } from "vue";
|
||||
import { ssrRenderAttrs, ssrRenderList, ssrInterpolate, ssrRenderClass } from "vue/server-renderer";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
__name: "index",
|
||||
__ssrInlineRender: true,
|
||||
setup(__props) {
|
||||
const superadminPerms = [
|
||||
{ key: "user_mgmt", label: "User Management", enabled: true },
|
||||
{ key: "billing_admin", label: "Billing Administration", enabled: true },
|
||||
{ key: "robot_config", label: "Robot Configuration", enabled: true },
|
||||
{ key: "system_logs", label: "System Logs Access", enabled: true },
|
||||
{ key: "api_keys", label: "API Key Management", enabled: false }
|
||||
];
|
||||
const garagePerms = [
|
||||
{ key: "vehicle_crud", label: "Vehicle CRUD", enabled: true },
|
||||
{ key: "service_booking", label: "Service Booking", enabled: true },
|
||||
{ key: "customer_view", label: "Customer Data View", enabled: true },
|
||||
{ key: "report_export", label: "Report Export", enabled: false },
|
||||
{ key: "bulk_import", label: "Bulk Import", enabled: false }
|
||||
];
|
||||
const packagePerms = [
|
||||
{ key: "premium_features", label: "Premium Features", enabled: true },
|
||||
{ key: "analytics", label: "Analytics Dashboard", enabled: true },
|
||||
{ key: "multi_garage", label: "Multi-Garage Support", enabled: false },
|
||||
{ key: "white_label", label: "White Label Branding", enabled: false },
|
||||
{ key: "api_access", label: "API Access", enabled: true }
|
||||
];
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
_push(`<div${ssrRenderAttrs(_attrs)}><div class="mb-8"><h1 class="text-2xl font-bold text-white">Jogosultság Kezelés</h1><p class="text-slate-400 mt-1">Permissions Management — Role-based access control matrix</p></div><div class="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-4 mb-8 flex items-start gap-3"><svg class="w-5 h-5 text-indigo-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><p class="text-sm text-indigo-300"> This is the permissions management interface. The 3D Capability Matrix UI (Superadmin, Garages, and Packages toggles) will be implemented here in the next iteration. </p></div><div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"><div class="bg-slate-800 rounded-xl border border-slate-700 p-6"><div class="flex items-center gap-3 mb-4"><div class="p-2 rounded-lg bg-purple-500/10 text-purple-400"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"></path></svg></div><h2 class="text-lg font-semibold text-white">Superadmin</h2></div><p class="text-sm text-slate-400 mb-4">Full system access — user management, billing, robots, and system configuration.</p><div class="space-y-3"><!--[-->`);
|
||||
ssrRenderList(superadminPerms, (perm) => {
|
||||
_push(`<div class="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0"><span class="text-sm text-slate-300">${ssrInterpolate(perm.label)}</span><span class="${ssrRenderClass([perm.enabled ? "bg-emerald-400/10 text-emerald-400" : "bg-slate-700 text-slate-500", "text-xs px-2 py-0.5 rounded-full"])}">${ssrInterpolate(perm.enabled ? "Enabled" : "Disabled")}</span></div>`);
|
||||
});
|
||||
_push(`<!--]--></div><div class="mt-4 pt-4 border-t border-slate-700"><button class="w-full px-4 py-2 text-sm bg-purple-600 hover:bg-purple-500 text-white rounded-lg transition"> Configure Superadmin </button></div></div><div class="bg-slate-800 rounded-xl border border-slate-700 p-6"><div class="flex items-center gap-3 mb-4"><div class="p-2 rounded-lg bg-cyan-500/10 text-cyan-400"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12"></path></svg></div><h2 class="text-lg font-semibold text-white">Garages</h2></div><p class="text-sm text-slate-400 mb-4">Garage-level access — vehicle management, service booking, customer data.</p><div class="space-y-3"><!--[-->`);
|
||||
ssrRenderList(garagePerms, (perm) => {
|
||||
_push(`<div class="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0"><span class="text-sm text-slate-300">${ssrInterpolate(perm.label)}</span><span class="${ssrRenderClass([perm.enabled ? "bg-emerald-400/10 text-emerald-400" : "bg-slate-700 text-slate-500", "text-xs px-2 py-0.5 rounded-full"])}">${ssrInterpolate(perm.enabled ? "Enabled" : "Disabled")}</span></div>`);
|
||||
});
|
||||
_push(`<!--]--></div><div class="mt-4 pt-4 border-t border-slate-700"><button class="w-full px-4 py-2 text-sm bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg transition"> Configure Garages </button></div></div><div class="bg-slate-800 rounded-xl border border-slate-700 p-6"><div class="flex items-center gap-3 mb-4"><div class="p-2 rounded-lg bg-amber-500/10 text-amber-400"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"></path></svg></div><h2 class="text-lg font-semibold text-white">Packages</h2></div><p class="text-sm text-slate-400 mb-4">Subscription package permissions — feature toggles per plan tier.</p><div class="space-y-3"><!--[-->`);
|
||||
ssrRenderList(packagePerms, (perm) => {
|
||||
_push(`<div class="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0"><span class="text-sm text-slate-300">${ssrInterpolate(perm.label)}</span><span class="${ssrRenderClass([perm.enabled ? "bg-emerald-400/10 text-emerald-400" : "bg-slate-700 text-slate-500", "text-xs px-2 py-0.5 rounded-full"])}">${ssrInterpolate(perm.enabled ? "Enabled" : "Disabled")}</span></div>`);
|
||||
});
|
||||
_push(`<!--]--></div><div class="mt-4 pt-4 border-t border-slate-700"><button class="w-full px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 text-white rounded-lg transition"> Configure Packages </button></div></div></div><div class="bg-slate-800/50 border-2 border-dashed border-slate-600 rounded-xl p-12 text-center"><svg class="w-12 h-12 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"></path></svg><h3 class="text-lg font-semibold text-slate-400 mb-2">3D Capability Matrix</h3><p class="text-sm text-slate-500 max-w-md mx-auto"> The interactive permission matrix UI with toggles for Superadmin, Garages, and Packages will be rendered here in the next development phase. </p></div></div>`);
|
||||
};
|
||||
}
|
||||
});
|
||||
const _sfc_setup = _sfc_main.setup;
|
||||
_sfc_main.setup = (props, ctx) => {
|
||||
const ssrContext = useSSRContext();
|
||||
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("pages/permissions/index.vue");
|
||||
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
|
||||
};
|
||||
export {
|
||||
_sfc_main as default
|
||||
};
|
||||
//# sourceMappingURL=index-nGH29Vk2.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/index-nGH29Vk2.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
71
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js
vendored
Normal file
71
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
import { _ as __nuxt_component_0 } from "./nuxt-link-DNVrgHUL.js";
|
||||
import { defineComponent, ref, mergeProps, unref, withCtx, createTextVNode, useSSRContext } from "vue";
|
||||
import { ssrRenderAttrs, ssrRenderAttr, ssrInterpolate, ssrIncludeBooleanAttr, ssrRenderComponent } from "vue/server-renderer";
|
||||
import { u as useAuthStore, _ as _imports_0 } from "./auth-CRAMW6e4.js";
|
||||
import { u as useRouter } from "../server.mjs";
|
||||
import "/app/node_modules/ufo/dist/index.mjs";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
import "#internal/nuxt/paths";
|
||||
import "pinia";
|
||||
import "./cookie-CWIsZYm7.js";
|
||||
import "/app/node_modules/cookie-es/dist/index.mjs";
|
||||
import "/app/node_modules/h3/dist/index.mjs";
|
||||
import "/app/node_modules/destr/dist/index.mjs";
|
||||
import "/app/node_modules/ohash/dist/index.mjs";
|
||||
import "/app/node_modules/klona/dist/index.mjs";
|
||||
import "/app/node_modules/ofetch/dist/node.mjs";
|
||||
import "/app/node_modules/hookable/dist/index.mjs";
|
||||
import "/app/node_modules/unctx/dist/index.mjs";
|
||||
import "vue-router";
|
||||
const _sfc_main = /* @__PURE__ */ defineComponent({
|
||||
__name: "login",
|
||||
__ssrInlineRender: true,
|
||||
setup(__props) {
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
useRouter();
|
||||
const authStore = useAuthStore();
|
||||
return (_ctx, _push, _parent, _attrs) => {
|
||||
const _component_NuxtLink = __nuxt_component_0;
|
||||
_push(`<div${ssrRenderAttrs(mergeProps({ class: "min-h-screen bg-slate-900 flex items-center justify-center px-4" }, _attrs))}><div class="w-full max-w-md"><div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img${ssrRenderAttr("src", _imports_0)} class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div><div class="bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8">`);
|
||||
if (unref(authStore).error) {
|
||||
_push(`<div class="mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm">${ssrInterpolate(unref(authStore).error)}</div>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`<form class="space-y-5"><div><label for="email" class="block text-sm font-medium text-slate-300 mb-1.5"> Email Address </label><input id="email"${ssrRenderAttr("value", unref(email))} type="email" autocomplete="email" required placeholder="admin@example.com" class="w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"></div><div><label for="password" class="block text-sm font-medium text-slate-300 mb-1.5"> Password </label><input id="password"${ssrRenderAttr("value", unref(password))} type="password" autocomplete="current-password" required placeholder="••••••••" class="w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"></div><button type="submit"${ssrIncludeBooleanAttr(unref(authStore).isLoading) ? " disabled" : ""} class="w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2">`);
|
||||
if (unref(authStore).isLoading) {
|
||||
_push(`<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>`);
|
||||
} else {
|
||||
_push(`<!---->`);
|
||||
}
|
||||
_push(`<span>${ssrInterpolate(unref(authStore).isLoading ? "Signing in..." : "Sign In")}</span></button></form><div class="mt-6 text-center">`);
|
||||
_push(ssrRenderComponent(_component_NuxtLink, {
|
||||
to: "/",
|
||||
class: "text-sm text-slate-500 hover:text-cyan-400 transition"
|
||||
}, {
|
||||
default: withCtx((_, _push2, _parent2, _scopeId) => {
|
||||
if (_push2) {
|
||||
_push2(` ← Back to ServiceFinder `);
|
||||
} else {
|
||||
return [
|
||||
createTextVNode(" ← Back to ServiceFinder ")
|
||||
];
|
||||
}
|
||||
}),
|
||||
_: 1
|
||||
}, _parent));
|
||||
_push(`</div></div></div></div>`);
|
||||
};
|
||||
}
|
||||
});
|
||||
const _sfc_setup = _sfc_main.setup;
|
||||
_sfc_main.setup = (props, ctx) => {
|
||||
const ssrContext = useSSRContext();
|
||||
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("pages/login.vue");
|
||||
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
|
||||
};
|
||||
export {
|
||||
_sfc_main as default
|
||||
};
|
||||
//# sourceMappingURL=login-CLIOTi6c.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/login-CLIOTi6c.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
307
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js
vendored
Normal file
307
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js
vendored
Normal file
@@ -0,0 +1,307 @@
|
||||
import { defineComponent, shallowRef, h, resolveComponent, computed, unref } from "vue";
|
||||
import { parseQuery, hasProtocol, joinURL, isScriptProtocol, withTrailingSlash, withoutTrailingSlash } from "/app/node_modules/ufo/dist/index.mjs";
|
||||
import { u as useRouter, e as encodeRoutePath, r as resolveRouteObject, n as navigateTo, a as useNuxtApp, b as useRuntimeConfig, c as nuxtLinkDefaults } from "../server.mjs";
|
||||
import "/app/node_modules/defu/dist/defu.mjs";
|
||||
const firstNonUndefined = (...args) => args.find((arg) => arg !== void 0);
|
||||
function sanitizeExternalHref(value) {
|
||||
let candidate = value.replace(/[\u0000-\u001f\s]+/g, "");
|
||||
while (candidate.toLowerCase().startsWith("view-source:")) {
|
||||
candidate = candidate.slice("view-source:".length);
|
||||
}
|
||||
const colon = candidate.indexOf(":");
|
||||
if (colon > 0 && isScriptProtocol(candidate.slice(0, colon + 1))) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// @__NO_SIDE_EFFECTS__
|
||||
function defineNuxtLink(options) {
|
||||
const componentName = options.componentName || "NuxtLink";
|
||||
function isHashLinkWithoutHashMode(link) {
|
||||
return typeof link === "string" && link.startsWith("#");
|
||||
}
|
||||
function resolveTrailingSlashBehavior(to, resolve, trailingSlash) {
|
||||
const effectiveTrailingSlash = trailingSlash ?? options.trailingSlash;
|
||||
if (!to || effectiveTrailingSlash !== "append" && effectiveTrailingSlash !== "remove") {
|
||||
return to;
|
||||
}
|
||||
if (typeof to === "string") {
|
||||
return applyTrailingSlashBehavior(to, effectiveTrailingSlash);
|
||||
}
|
||||
const path = "path" in to && to.path !== void 0 ? to.path : resolve(to).path;
|
||||
const resolvedPath = {
|
||||
...to,
|
||||
name: void 0,
|
||||
// named routes would otherwise always override trailing slash behavior
|
||||
path: applyTrailingSlashBehavior(path, effectiveTrailingSlash)
|
||||
};
|
||||
return resolvedPath;
|
||||
}
|
||||
function useNuxtLink(props) {
|
||||
const router = useRouter();
|
||||
const config = useRuntimeConfig();
|
||||
const hasTarget = computed(() => !!unref(props.target) && unref(props.target) !== "_self");
|
||||
const isAbsoluteUrl = computed(() => {
|
||||
const path = unref(props.to) || unref(props.href) || "";
|
||||
return typeof path === "string" && hasProtocol(path, { acceptRelative: true });
|
||||
});
|
||||
const builtinRouterLink = resolveComponent("RouterLink");
|
||||
const useBuiltinLink = builtinRouterLink && typeof builtinRouterLink !== "string" ? builtinRouterLink.useLink : void 0;
|
||||
const isExternal = computed(() => {
|
||||
if (unref(props.external)) {
|
||||
return true;
|
||||
}
|
||||
const path = unref(props.to) || unref(props.href) || "";
|
||||
if (typeof path === "object") {
|
||||
return false;
|
||||
}
|
||||
return path === "" || isAbsoluteUrl.value;
|
||||
});
|
||||
const to = computed(() => {
|
||||
const path = unref(props.to) || unref(props.href) || "";
|
||||
if (isExternal.value) {
|
||||
return path;
|
||||
}
|
||||
return resolveTrailingSlashBehavior(path, router.resolve, unref(props.trailingSlash));
|
||||
});
|
||||
const link = isExternal.value ? void 0 : useBuiltinLink?.({ ...props, to, viewTransition: unref(props.viewTransition) });
|
||||
const href = computed(() => {
|
||||
const effectiveTrailingSlash = unref(props.trailingSlash) ?? options.trailingSlash;
|
||||
if (!to.value || isAbsoluteUrl.value || isHashLinkWithoutHashMode(to.value)) {
|
||||
const raw = to.value;
|
||||
return typeof raw === "string" ? sanitizeExternalHref(raw) : raw;
|
||||
}
|
||||
if (isExternal.value) {
|
||||
const path = typeof to.value === "object" && "path" in to.value ? resolveRouteObject(to.value) : to.value;
|
||||
const href2 = typeof path === "object" ? router.resolve(path).href : path;
|
||||
const safe = typeof href2 === "string" ? sanitizeExternalHref(href2) : href2;
|
||||
return safe === null ? null : applyTrailingSlashBehavior(safe, effectiveTrailingSlash);
|
||||
}
|
||||
if (typeof to.value === "object") {
|
||||
return router.resolve(to.value)?.href ?? null;
|
||||
}
|
||||
return applyTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), effectiveTrailingSlash);
|
||||
});
|
||||
return {
|
||||
to,
|
||||
hasTarget,
|
||||
isAbsoluteUrl,
|
||||
isExternal,
|
||||
//
|
||||
href,
|
||||
isActive: link?.isActive ?? computed(() => to.value === router.currentRoute.value.path),
|
||||
isExactActive: link?.isExactActive ?? computed(() => to.value === router.currentRoute.value.path),
|
||||
route: link?.route ?? computed(() => router.resolve(to.value)),
|
||||
async navigate(_e) {
|
||||
if (href.value === null) {
|
||||
return;
|
||||
}
|
||||
await navigateTo(href.value, { replace: unref(props.replace), external: isExternal.value || hasTarget.value });
|
||||
}
|
||||
};
|
||||
}
|
||||
return defineComponent({
|
||||
name: componentName,
|
||||
props: {
|
||||
// Routing
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
href: {
|
||||
type: [String, Object],
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Attributes
|
||||
target: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
rel: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
noRel: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Prefetching
|
||||
prefetch: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
prefetchOn: {
|
||||
type: [String, Object],
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
noPrefetch: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Styling
|
||||
activeClass: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
exactActiveClass: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
prefetchedClass: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Vue Router's `<RouterLink>` additional props
|
||||
replace: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
ariaCurrentValue: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Edge cases handling
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Slot API
|
||||
custom: {
|
||||
type: Boolean,
|
||||
default: void 0,
|
||||
required: false
|
||||
},
|
||||
// Behavior
|
||||
trailingSlash: {
|
||||
type: String,
|
||||
default: void 0,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
useLink: useNuxtLink,
|
||||
setup(props, { slots }) {
|
||||
const router = useRouter();
|
||||
const { to, href, navigate, isExternal, hasTarget, isAbsoluteUrl } = useNuxtLink(props);
|
||||
shallowRef(false);
|
||||
const el = void 0;
|
||||
const elRef = void 0;
|
||||
async function prefetch(nuxtApp = useNuxtApp()) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (!isExternal.value && !hasTarget.value && !isHashLinkWithoutHashMode(to.value)) {
|
||||
const routerLinkProps = {
|
||||
ref: elRef,
|
||||
to: to.value,
|
||||
activeClass: props.activeClass || options.activeClass,
|
||||
exactActiveClass: props.exactActiveClass || options.exactActiveClass,
|
||||
replace: props.replace,
|
||||
ariaCurrentValue: props.ariaCurrentValue,
|
||||
custom: props.custom
|
||||
};
|
||||
if (!props.custom) {
|
||||
routerLinkProps.rel = props.rel || void 0;
|
||||
}
|
||||
return h(
|
||||
resolveComponent("RouterLink"),
|
||||
routerLinkProps,
|
||||
slots.default
|
||||
);
|
||||
}
|
||||
const target = props.target || null;
|
||||
const rel = firstNonUndefined(
|
||||
// converts `""` to `null` to prevent the attribute from being added as empty (`rel=""`)
|
||||
props.noRel ? "" : props.rel,
|
||||
options.externalRelAttribute,
|
||||
/*
|
||||
* A fallback rel of `noopener noreferrer` is applied for external links or links that open in a new tab.
|
||||
* This solves a reverse tabnapping security flaw in browsers pre-2021 as well as improving privacy.
|
||||
*/
|
||||
isAbsoluteUrl.value || hasTarget.value ? "noopener noreferrer" : ""
|
||||
) || null;
|
||||
if (props.custom) {
|
||||
if (!slots.default) {
|
||||
return null;
|
||||
}
|
||||
return slots.default({
|
||||
href: href.value,
|
||||
navigate,
|
||||
prefetch,
|
||||
get route() {
|
||||
if (!href.value) {
|
||||
return void 0;
|
||||
}
|
||||
const url = new URL(href.value, "http://localhost");
|
||||
return {
|
||||
path: url.pathname,
|
||||
fullPath: url.pathname,
|
||||
get query() {
|
||||
return parseQuery(url.search);
|
||||
},
|
||||
hash: url.hash,
|
||||
params: {},
|
||||
name: void 0,
|
||||
matched: [],
|
||||
redirectedFrom: void 0,
|
||||
meta: {},
|
||||
href: href.value
|
||||
};
|
||||
},
|
||||
rel,
|
||||
target,
|
||||
isExternal: isExternal.value || hasTarget.value,
|
||||
isActive: false,
|
||||
isExactActive: false
|
||||
});
|
||||
}
|
||||
return h("a", {
|
||||
ref: el,
|
||||
href: href.value || null,
|
||||
// converts `""` to `null` to prevent the attribute from being added as empty (`href=""`)
|
||||
rel,
|
||||
target,
|
||||
onClick: async (event) => {
|
||||
if (isExternal.value || hasTarget.value) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
try {
|
||||
const encodedHref = encodeRoutePath(href.value ?? "");
|
||||
return await (props.replace ? router.replace(encodedHref) : router.push(encodedHref));
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
}, slots.default?.());
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
const __nuxt_component_0 = /* @__PURE__ */ defineNuxtLink(nuxtLinkDefaults);
|
||||
function applyTrailingSlashBehavior(to, trailingSlash) {
|
||||
const normalizeFn = trailingSlash === "append" ? withTrailingSlash : withoutTrailingSlash;
|
||||
const hasProtocolDifferentFromHttp = hasProtocol(to) && !to.startsWith("http");
|
||||
if (hasProtocolDifferentFromHttp) {
|
||||
return to;
|
||||
}
|
||||
return normalizeFn(to, true);
|
||||
}
|
||||
export {
|
||||
__nuxt_component_0 as _
|
||||
};
|
||||
//# sourceMappingURL=nuxt-link-DNVrgHUL.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/nuxt-link-DNVrgHUL.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
21
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js
vendored
Normal file
21
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import { hasInjectionContext, inject } from "vue";
|
||||
import { useHead as useHead$1, headSymbol } from "/app/node_modules/@unhead/vue/dist/index.mjs";
|
||||
import { t as tryUseNuxtApp } from "../server.mjs";
|
||||
function injectHead(nuxtApp) {
|
||||
const nuxt = nuxtApp || tryUseNuxtApp();
|
||||
return nuxt?.ssrContext?.head || nuxt?.runWithContext(() => {
|
||||
if (hasInjectionContext()) {
|
||||
return inject(headSymbol);
|
||||
}
|
||||
});
|
||||
}
|
||||
function useHead(input, options = {}) {
|
||||
const head = injectHead(options.nuxt);
|
||||
if (head) {
|
||||
return useHead$1(input, { head, ...options });
|
||||
}
|
||||
}
|
||||
export {
|
||||
useHead as u
|
||||
};
|
||||
//# sourceMappingURL=v3-DnDMBKvA.js.map
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"v3-DnDMBKvA.js","sources":["../../../../node_modules/nuxt/dist/head/runtime/composables/v3.js"],"sourcesContent":["import { hasInjectionContext, inject } from \"vue\";\nimport {\n useHead as headCore,\n useHeadSafe as headSafe,\n headSymbol,\n useSeoMeta as seoMeta,\n useServerHead as serverHead,\n useServerHeadSafe as serverHeadSafe,\n useServerSeoMeta as serverSeoMeta\n} from \"@unhead/vue\";\nimport { tryUseNuxtApp } from \"#app/nuxt\";\nexport function injectHead(nuxtApp) {\n const nuxt = nuxtApp || tryUseNuxtApp();\n return nuxt?.ssrContext?.head || nuxt?.runWithContext(() => {\n if (hasInjectionContext()) {\n return inject(headSymbol);\n }\n });\n}\nexport function useHead(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return headCore(input, { head, ...options });\n }\n}\nexport function useHeadSafe(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return headSafe(input, { head, ...options });\n }\n}\nexport function useSeoMeta(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return seoMeta(input, { head, ...options });\n }\n}\nexport function useServerHead(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverHead(input, { head, ...options });\n }\n}\nexport function useServerHeadSafe(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverHeadSafe(input, { head, ...options });\n }\n}\nexport function useServerSeoMeta(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverSeoMeta(input, { head, ...options });\n }\n}\n"],"names":["headCore"],"mappings":";;;AAWO,SAAS,WAAW,SAAS;AAClC,QAAM,OAAO,WAAW,cAAa;AACrC,SAAO,MAAM,YAAY,QAAQ,MAAM,eAAe,MAAM;AAC1D,QAAI,oBAAmB,GAAI;AACzB,aAAO,OAAO,UAAU;AAAA,IAC1B;AAAA,EACF,CAAC;AACH;AACO,SAAS,QAAQ,OAAO,UAAU,IAAI;AAC3C,QAAM,OAAO,WAAW,QAAQ,IAAI;AACpC,MAAI,MAAM;AACR,WAAOA,UAAS,OAAO,EAAE,MAAM,GAAG,QAAO,CAAE;AAAA,EAC7C;AACF;","x_google_ignoreList":[0]}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/v3-DnDMBKvA.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"v3-DnDMBKvA.js","mappings":";;;AAWO,SAAS,WAAW,SAAS;AAClC,QAAM,OAAO,WAAW,cAAa;AACrC,SAAO,MAAM,YAAY,QAAQ,MAAM,eAAe,MAAM;AAC1D,QAAI,oBAAmB,GAAI;AACzB,aAAO,OAAO,UAAU;AAAA,IAC1B;AAAA,EACF,CAAC;AACH;AACO,SAAS,QAAQ,OAAO,UAAU,IAAI;AAC3C,QAAM,OAAO,WAAW,QAAQ,IAAI;AACpC,MAAI,MAAM;AACR,WAAOA,UAAS,OAAO,EAAE,MAAM,GAAG,QAAO,CAAE;AAAA,EAC7C;AACF;","names":["headCore"],"sources":["../../../../node_modules/nuxt/dist/head/runtime/composables/v3.js"],"sourcesContent":["import { hasInjectionContext, inject } from \"vue\";\nimport {\n useHead as headCore,\n useHeadSafe as headSafe,\n headSymbol,\n useSeoMeta as seoMeta,\n useServerHead as serverHead,\n useServerHeadSafe as serverHeadSafe,\n useServerSeoMeta as serverSeoMeta\n} from \"@unhead/vue\";\nimport { tryUseNuxtApp } from \"#app/nuxt\";\nexport function injectHead(nuxtApp) {\n const nuxt = nuxtApp || tryUseNuxtApp();\n return nuxt?.ssrContext?.head || nuxt?.runWithContext(() => {\n if (hasInjectionContext()) {\n return inject(headSymbol);\n }\n });\n}\nexport function useHead(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return headCore(input, { head, ...options });\n }\n}\nexport function useHeadSafe(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return headSafe(input, { head, ...options });\n }\n}\nexport function useSeoMeta(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return seoMeta(input, { head, ...options });\n }\n}\nexport function useServerHead(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverHead(input, { head, ...options });\n }\n}\nexport function useServerHeadSafe(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverHeadSafe(input, { head, ...options });\n }\n}\nexport function useServerSeoMeta(input, options = {}) {\n const head = injectHead(options.nuxt);\n if (head) {\n return serverSeoMeta(input, { head, ...options });\n }\n}\n"],"version":3}
|
||||
1
frontend_admin/.nuxt/dist/server/client.manifest.mjs
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/client.manifest.mjs
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export default ({"_BoAo7BJa.js":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"BoAo7BJa.js",name:"cookie",imports:["node_modules/nuxt/dist/app/entry.js"]},"_ByPKVH1D.js":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"ByPKVH1D.js",name:"auth",imports:["node_modules/nuxt/dist/app/entry.js","_BoAo7BJa.js"]},"_Di-57MrI.js":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"Di-57MrI.js",name:"v3",imports:["node_modules/nuxt/dist/app/entry.js"]},"_QKJPEFuj.js":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"QKJPEFuj.js",name:"nuxt-link",imports:["node_modules/nuxt/dist/app/entry.js"]},"layouts/default.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"BW9w-mbE.js",name:"default",src:"layouts/default.vue",isDynamicEntry:!0,imports:["_QKJPEFuj.js","node_modules/nuxt/dist/app/entry.js","_ByPKVH1D.js","_BoAo7BJa.js"]},"middleware/auth.ts":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"BFfxzM5G.js",name:"auth",src:"middleware/auth.ts",isDynamicEntry:!0,imports:["node_modules/nuxt/dist/app/entry.js","_BoAo7BJa.js"]},"node_modules/nuxt/dist/app/components/error-404.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"Oih3kTQ2.js",name:"error-404",src:"node_modules/nuxt/dist/app/components/error-404.vue",isDynamicEntry:!0,imports:["_QKJPEFuj.js","node_modules/nuxt/dist/app/entry.js","_Di-57MrI.js"],css:[]},"error-404.DL_4WIao.css":{file:"error-404.DL_4WIao.css",resourceType:"style",prefetch:!0,preload:!0},"node_modules/nuxt/dist/app/components/error-500.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"usmOON9X.js",name:"error-500",src:"node_modules/nuxt/dist/app/components/error-500.vue",isDynamicEntry:!0,imports:["node_modules/nuxt/dist/app/entry.js","_Di-57MrI.js"],css:[]},"error-500.I1Dtv2V5.css":{file:"error-500.I1Dtv2V5.css",resourceType:"style",prefetch:!0,preload:!0},"node_modules/nuxt/dist/app/entry.js":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"C3Ck-LNv.js",name:"entry",src:"node_modules/nuxt/dist/app/entry.js",isEntry:!0,dynamicImports:["middleware/auth.ts","layouts/default.vue","node_modules/nuxt/dist/app/components/error-404.vue","node_modules/nuxt/dist/app/components/error-500.vue"],css:[]},"entry.KrfumMBl.css":{file:"entry.KrfumMBl.css",resourceType:"style",prefetch:!0,preload:!0},"pages/index.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"Drr7KP9v.js",name:"index",src:"pages/index.vue",isDynamicEntry:!0,imports:["node_modules/nuxt/dist/app/entry.js"]},"pages/login.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"XMkMKtwS.js",name:"login",src:"pages/login.vue",isDynamicEntry:!0,imports:["_QKJPEFuj.js","node_modules/nuxt/dist/app/entry.js","_ByPKVH1D.js","_BoAo7BJa.js"]},"pages/permissions/index.vue":{resourceType:"script",module:!0,prefetch:!0,preload:!0,file:"CyOWWS80.js",name:"index",src:"pages/permissions/index.vue",isDynamicEntry:!0,imports:["node_modules/nuxt/dist/app/entry.js"]}})
|
||||
1
frontend_admin/.nuxt/dist/server/client.precomputed.mjs
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/client.precomputed.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
1298
frontend_admin/.nuxt/dist/server/server.mjs
vendored
Normal file
1298
frontend_admin/.nuxt/dist/server/server.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend_admin/.nuxt/dist/server/server.mjs.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/server.mjs.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/server.mjs.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/server.mjs.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user