admin felület különválasztva

This commit is contained in:
Roo
2026-06-24 11:29:45 +00:00
parent 71ef33bb85
commit 80a5d67f79
462 changed files with 87873 additions and 312 deletions

View File

@@ -202,3 +202,227 @@ A `GET /expenses/` endpoint (`backend/app/api/v1/endpoints/expenses.py:130-139`)
### 📄 Dokumentáció ### 📄 Dokumentáció
`docs/cost_pipeline_audit.md` - Teljes pipeline audit a frontendtől a DB-ig. `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`

View File

@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/deps.py # /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 import logging
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -177,6 +177,65 @@ async def get_current_admin(
return current_user 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) # RBAC Phase 2: Capability Dependencies (Kapuőrök)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════

View File

@@ -130,6 +130,7 @@ async def login(
httponly=True, httponly=True,
secure=True, # Use Secure secure=True, # Use Secure
samesite="lax", samesite="lax",
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
max_age=max_age_sec max_age=max_age_sec
) )
@@ -224,6 +225,7 @@ async def verify_email(
httponly=True, httponly=True,
secure=True, secure=True,
samesite="lax", samesite="lax",
domain=settings.COOKIE_DOMAIN, # Cross-subdomain SSO
max_age=max_age_sec max_age=max_age_sec
) )

View File

@@ -2,6 +2,7 @@
""" """
Szótárak és katalógusok végpontjai. Szótárak és katalógusok végpontjai.
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve - 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 - CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
""" """
import logging import logging
@@ -17,6 +18,7 @@ from app.schemas.fleet_finance import (
InsuranceProviderCreate, InsuranceProviderCreate,
InsuranceProviderUpdate, InsuranceProviderUpdate,
) )
from app.services.capability_service import resolve_org_capabilities
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) 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. 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). 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) stmt = select(CostCategory).order_by(CostCategory.name)
if visibility: if visibility:
@@ -46,6 +62,14 @@ async def list_cost_categories(
result = await db.execute(stmt) result = await db.execute(stmt)
categories = result.scalars().all() 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 # Hierarchikus struktúra építése
category_map = {} category_map = {}
root_categories = [] root_categories = []
@@ -60,6 +84,8 @@ async def list_cost_categories(
"visibility": cat.visibility, "visibility": cat.visibility,
"accounting_code": cat.accounting_code, "accounting_code": cat.accounting_code,
"is_system": cat.is_system, "is_system": cat.is_system,
"min_tier": cat.min_tier,
"required_feature": cat.required_feature,
"children": [], "children": [],
} }
category_map[cat.id] = cat_dict category_map[cat.id] = cat_dict

View File

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability 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.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 from datetime import datetime, timezone
logger = logging.getLogger(__name__) 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}") @router.get("/{asset_id}")
async def list_asset_expenses( async def list_asset_expenses(
asset_id: UUID, 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." 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 # P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user.
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id # The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21).
if not organization_id: # The vendor is stored separately via vendor_organization_id.
# B2C fallback: if the asset is owned by a person (not an org), #
# try to find the current user's default organization # 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( org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id, OrganizationMember.user_id == current_user.id,
OrganizationMember.is_verified == True 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
else:
# Last resort: any membership (even unverified)
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id
).limit(1) ).limit(1)
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none() org_member = org_result.scalar_one_or_none()

View File

@@ -778,13 +778,13 @@ async def assign_organization_subscription(
body: SubscriptionAssignIn, body: SubscriptionAssignIn,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), 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). 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. 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: The endpoint:
1. Validates the tier exists in system.subscription_tiers 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 3. Updates subscription_plan and base_asset_limit from tier rules
4. Creates/updates a finance.org_subscriptions audit record 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: try:
result = await upgrade_org_subscription( result = await upgrade_org_subscription(
db=db, db=db,

View File

@@ -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)"), 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ő"), 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"), 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"), 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"), offset: int = Query(0, ge=0, description="Lapozási offset"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -224,6 +225,7 @@ async def search_service_providers(
category=category, category=category,
city=city, city=city,
category_ids=parsed_category_ids, category_ids=parsed_category_ids,
org_type=org_type,
limit=limit, limit=limit,
offset=offset, offset=offset,
) )

View File

@@ -86,6 +86,7 @@ class Settings(BaseSettings):
# --- External URLs --- # --- External URLs ---
FRONTEND_BASE_URL: str = "https://app.servicefinder.hu" FRONTEND_BASE_URL: str = "https://app.servicefinder.hu"
COOKIE_DOMAIN: str = ".servicefinder.hu" # Cross-subdomain SSO cookie domain
BACKEND_CORS_ORIGINS: List[str] = Field( BACKEND_CORS_ORIGINS: List[str] = Field(
default=[ default=[
# Production domains # Production domains

View File

@@ -22,6 +22,16 @@ class SubscriptionTier(Base):
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5} rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
is_custom: Mapped[bool] = mapped_column(Boolean, default=False) 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): class OrganizationSubscription(Base):
""" """
Szervezetek aktuális előfizetései és azok érvényessége. Szervezetek aktuális előfizetései és azok érvényessége.

View File

@@ -56,6 +56,17 @@ class CostCategory(Base):
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'") 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'") min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'")
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) 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()) 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()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())

View File

@@ -151,6 +151,17 @@ class Organization(Base):
index=True 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")) 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")) 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 # Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities") 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): class OrganizationFinancials(Base):
__tablename__ = "organization_financials" __tablename__ = "organization_financials"

View File

@@ -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") 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)") 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): class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs.""" """Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID id: UUID

View 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

View File

@@ -141,6 +141,7 @@ async def search_providers(
category: Optional[str] = None, category: Optional[str] = None,
city: Optional[str] = None, city: Optional[str] = None,
category_ids: Optional[List[int]] = None, category_ids: Optional[List[int]] = None,
org_type: Optional[str] = None,
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> ProviderSearchResponse: ) -> ProviderSearchResponse:
@@ -174,6 +175,7 @@ async def search_providers(
category: Kategória szűrő category: Kategória szűrő
city: Város szűrő — AND kapcsolat a q-val city: Város szűrő — AND kapcsolat a q-val
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés) 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 limit: Lapozási limit
offset: Lapozási offset offset: Lapozási offset
@@ -190,9 +192,12 @@ async def search_providers(
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) --- # --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
org_conditions = [ org_conditions = [
Organization.org_type == OrgType.service_provider,
Organization.is_deleted == False, 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: if q_tokens:
token_conditions = [] token_conditions = []
for token in q_tokens: for token in q_tokens:
@@ -630,25 +635,10 @@ async def quick_add_provider(
db.add(branch) 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). # DÖNTÉS: A létrehozó felhasználó NEM lehet tagja a beszállító szervezetnek.
# Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). # A szerviz jöjjön létre owner_id=None beállítással, tagok nélkül.
# 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)"
)
# ===================================================================== # =====================================================================
# ===================================================================== # =====================================================================

View File

@@ -79,6 +79,36 @@ COST_CATEGORY_TREE = [
("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"), ("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"),
],
},
] ]
# ============================================================================= # =============================================================================

View File

@@ -280,7 +280,7 @@ services:
# --- NUXT 3 ADMIN FRONTEND (Epic 10 - Mission Control) --- # --- NUXT 3 ADMIN FRONTEND (Epic 10 - Mission Control) ---
sf_admin_frontend: sf_admin_frontend:
build: build:
context: ./frontend/admin context: ./frontend_admin
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: sf_admin_frontend container_name: sf_admin_frontend
env_file: .env env_file: .env
@@ -291,7 +291,7 @@ services:
- NUXT_HOST=0.0.0.0 - NUXT_HOST=0.0.0.0
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu - NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
volumes: volumes:
- ./frontend/admin:/app - ./frontend_admin:/app
- /app/node_modules - /app/node_modules
networks: networks:
- sf_net - sf_net
@@ -301,7 +301,7 @@ services:
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) --- # --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
sf_public_frontend: sf_public_frontend:
build: build:
context: ./frontend context: ./frontend_app
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: sf_public_frontend container_name: sf_public_frontend
env_file: .env env_file: .env
@@ -310,7 +310,7 @@ services:
environment: environment:
- VITE_API_BASE_URL=/api/v1 - VITE_API_BASE_URL=/api/v1
volumes: volumes:
- ./frontend:/app - ./frontend_app:/app
- /app/node_modules - /app/node_modules
networks: networks:
- sf_net - sf_net
@@ -371,7 +371,7 @@ services:
image: mcr.microsoft.com/playwright:v1.58.2-jammy image: mcr.microsoft.com/playwright:v1.58.2-jammy
container_name: sf_tester container_name: sf_tester
volumes: volumes:
- ./frontend:/app - ./frontend_app:/app
- sf_tester_node_modules:/app/node_modules - sf_tester_node_modules:/app/node_modules
networks: networks:
- sf_net - sf_net

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

View 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 127134, 221228)
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 160177)
```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 184218)
```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 112137)
```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 166177)
```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 103110)
```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 |

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

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

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

View File

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

View File

@@ -1,3 +0,0 @@
export default defineNuxtConfig({
devtools: { enabled: true }
})

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

View File

@@ -0,0 +1 @@
body{margin:0;padding:0}

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

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

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

View File

@@ -0,0 +1,4 @@
import style_0 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0
]

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

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

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

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-1.mjs-CF-ChPYj.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-1.mjs-CF-ChPYj.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-2.mjs-CntxBZab.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-2.mjs-CntxBZab.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-3.mjs-C1rWf53M.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-3.mjs-C1rWf53M.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

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

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"error-404-styles-1.mjs-COQ9lBg6.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"error-404-styles-1.mjs-COQ9lBg6.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -0,0 +1,4 @@
import style_0 from "./error-404-styles-1.mjs-COQ9lBg6.js";
export default [
style_0
]

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

View File

@@ -0,0 +1 @@
{"version":3,"file":"error-500-styles-1.mjs-C0glhSuM.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"error-500-styles-1.mjs-C0glhSuM.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -0,0 +1,4 @@
import style_0 from "./error-500-styles-1.mjs-C0glhSuM.js";
export default [
style_0
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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

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

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

View 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"]}})

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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