admin_users_personels

This commit is contained in:
Roo
2026-06-29 14:11:15 +00:00
parent 383e5511b6
commit df4a0f07ff
237 changed files with 17849 additions and 2934 deletions

View File

@@ -950,3 +950,145 @@ A backend `admin_organizations.py` fájlban két külön kódút (Path A és Pat
- Python syntax check: ✅ OK (`py_compile` sikeres) - Python syntax check: ✅ OK (`py_compile` sikeres)
- Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban - Sync engine: 1279 OK, 0 Fixed, 0 Shadow — teljes szinkronban
- A `SubscriptionSummary` most helyesen kapja meg a Base limit értékeket az `_extract_limit()`-en keresztül, mielőtt az Extra allowances hozzáadódnak. - A `SubscriptionSummary` most helyesen kapja meg a Base limit értékeket az `_extract_limit()`-en keresztül, mielőtt az Extra allowances hozzáadódnak.
## 2026-06-29 - Gitea #319: Felhasználói trend diagram komponens (EPIC-5, task 5.2)
### 🎯 Cél
Felhasználói regisztrációs trend diagram komponens elkészítése az admin felület users list oldalára.
### 🔧 Változtatások
**1. UserTrendChart.vue komponens létrehozása:** `frontend_admin/components/charts/UserTrendChart.vue`
- Pure SVG bar chart (nincs külső chart library függőség)
- Period selector: 7/14/30/90 nap
- Grid lines, Y-axis labels, hover tooltips
- Trend direction indicator (up/down/stable)
- Summary stats: daily avg, total, trend %
- Loading és empty state kezelés
**2. i18n kulcsok hozzáadása:** `frontend_admin/locales/hu.json`, `frontend_admin/locales/en.json`
- trend_title, trend_subtitle, trend_7d/14d/30d/90d
- trend_no_data, trend_daily, trend_avg, trend_total, trend_vs_prev
**3. Integrálás a users oldalba:** `frontend_admin/pages/users/index.vue`
- Import: `import UserTrendChart from '~/components/charts/UserTrendChart.vue'`
- Template: `<UserTrendChart :data="stats.registration_trend" :loading="loading" />`
- A stats kártyák és a filter bar közé helyezve
### ✅ Eredmény
- A backend `GET /admin/users/stats` endpoint már létezik és visszaadja a `registration_trend` adatokat
- A frontend build hibátlanul lefutott a `sf_admin_frontend` konténerben
- A komponens automatikusan használja a meglévő `stats.registration_trend` adatokat
## 2026-06-29 - EPIC-6 Task 6.1: i18n Fordítások Felvétele (hu/en) - User & Person Oldalak
### 🎯 Cél
i18n fordítások (hu/en) hozzáadása a frontend_admin user és person kezelő oldalakhoz a [`plans/logic_spec_admin_user_person_management.md`](plans/logic_spec_admin_user_person_management.md) specifikáció alapján (EPIC-6, task 6.1).
### 🔧 Módosított fájlok
- [`frontend_admin/locales/en.json`](frontend_admin/locales/en.json) - Teljes `users` szekció hozzáadva (users.* és users.details.* kulcsok); JSON szintaxis hiba javítva (hiányzó vessző a persons.details után)
- [`frontend_admin/locales/hu.json`](frontend_admin/locales/hu.json) - Teljes `users` szekció hozzáadva magyar fordításokkal; JSON szintaxis hiba javítva
### ✅ Eredmények
- **JSON validáció:** ✅ Sikeres (python3 json.load)
- **Nuxt build:** ✅ Sikeres (6.78s, AdminUsersView chunk: 19.93 kB)
- **Hiányzó kulcsok pótolva:** `users.title`, `users.subtitle`, `users.search_placeholder`, `users.*` (status, role, plan filterek), `users.details.*` (profil, subscription, személyes adatok, tagságok)
- **JSON hiba javítva:** Hiányzó vessző a `persons.details` objektum záró kapcsos zárójele után (224. sor) mindkét locale fájlban
## 2026-06-29 — RBAC Permission Check Implementation (Gitea #321)
### 🎯 Cél
Card #321: "6.2: RBAC permission check implementáció" — Minden admin user/person oldalnak ellenőriznie kell a DB-driven RBAC permissionöket a spec szerint.
### 🔧 Módosított fájlok
- [`frontend_admin/pages/users/index.vue`](frontend_admin/pages/users/index.vue) — `useAuthStore`/`useRouter` importok, `canViewUsers`/`canEditUsers`/`canBanUsers`/`canDeleteUsers` computed property-k, redirect guard `users:view` hiányában, `v-if="canEditUsers"` a bulk actions bar-on és toggle gombokon
- [`frontend_admin/pages/users/[id]/index.vue`](frontend_admin/pages/users/%5Bid%5D/index.vue) — `hasEditPermission` átírva legacy `'user:edit'`-ről (singular) `'users:edit'`-re (plural, spec szerint)
- [`frontend_admin/pages/persons/index.vue`](frontend_admin/pages/persons/index.vue) — `useAuthStore`/`useRouter` importok, `canViewPersons`/`canEditPersons`/`canMergePersons` computed property-k, redirect guard `persons:view` hiányában
- [`frontend_admin/pages/persons/[id]/index.vue`](frontend_admin/pages/persons/%5Bid%5D/index.vue) — `hasEditPermission` egyszerűsítve `!!` koercícióra
### ✅ Verifikáció
- **Nuxt build:** ✅ Sikeres (`docker compose exec sf_admin_frontend npm run build`)
- **Permission kódok:** `users:view`, `users:edit`, `users:ban`, `users:delete`, `persons:view`, `persons:edit`, `persons:merge` — mind implementálva a spec (5.8) szerint
- **Fallback:** Minden computed property `auth.isAdmin`-re esik vissza, ha nincs `system_capabilities`
## 2026-06-29 - Admin Users Filter Buttons Fix (#323)
### 🎯 Cél
Az admin felületen a felhasználók listájában nem működő szűrő gombok (státusz, szerepkör, csomag) javítása.
### 🔧 Módosított fájlok
- **`backend/app/api/v1/endpoints/admin.py`**:
- `plan_filter` query paraméter hozzáadása a `list_users()` végponthoz
- `plan_filter` szűrési logika: `User.subscription_plan == plan_filter.upper()`
- `person_name` mező hozzáadása a válaszhoz (last_name + first_name összefűzés)
- Case-insensitive role matching: `role.upper()` a `UserRole` enumhoz
- **`frontend_admin/pages/users/index.vue`**:
- `status_filter``is_active`/`is_deleted` boolean mapping (switch/case)
- `role_filter``role` paraméter átnevezés
- `plan_filter` paraméter megtartva (backend most már támogatja)
### ✅ Eredmények
- API szintaxis ellenőrzés: ✅ PASS
- API végpont tesztek (7 teszt): ✅ ALL PASS
- Basic list: 34 user, person_name megjelenik
- role=admin (lowercase): 2 admin (case-insensitive)
- role=user (lowercase): 30 user
- is_active=true: 16 aktív
- plan_filter=FREE: 28 FREE csomagos
- Combined (role=user, is_active=true, plan_filter=PREMIUM): 2 találat
- is_deleted=true: 1 törölt
## 2026-06-29 - Architect: Gamification Admin Integration Audit & Gitea Kártyák
### 🎯 Cél
A meglévő Gamification rendszer (backend modellek, service-ek, API végpontok) teljes körű auditálása, komplex specifikáció készítése az admin felületre történő bekötéshez, és 15 Gitea implementációs kártya létrehozása.
### 🔧 Elvégzett Munka
**1. GAMIFICATION RENDSZER AUDIT:**
- Feltárt 10+ adatbázis tábla a `gamification` sémában (point_rules, level_configs, points_ledger, user_stats, badges, user_badges, user_contributions, seasons, seasonal_competitions, competitions, user_competition_scores)
- Azonosított hiányzó mezők: UserStats (services_submitted, total_points), PointsLedger (xp, source_type, source_id)
- Feltárt 14+ meglévő API végpont a `/gamification/*` útvonalon
- Feltárt hibás végpont: `PATCH /admin/users/{id}/penalty` direkt `level` módosítás a GamificationService helyett
- Feltárt 3 business logic probléma (szolgáltatáskikerülés, GamificationService bypass)
**2. KOMPLEX SPECIFIKÁCIÓ:**
- Létrehozva: [`plans/logic_spec_gamification_admin_integration.md`](plans/logic_spec_gamification_admin_integration.md)
- 30+ új admin API endpoint terv az `admin_gamification.py` routerben
- 12 új frontend oldal terv a `frontend_admin/pages/gamification/` mappában
- Új Gamification menücsoport terv 6 menüponttal
- Adatbázis séma javítások (UserStats, PointsLedger)
- Business logic javítások (penalty endpoint, GamificationService bypass)
- RBAC: `gamification:manage` permission
- Adatfolyam diagram (Mermaid)
- 15 implementációs kártya prioritási sorrenddel és függőségekkel
**3. GITEA KÁRTYÁK LÉTREHOZÁSA:**
15 kártya a `Epic 8 Gamification 2.0, Verseny és Önvéde` mérföldkő alatt:
| # | ID | Kártya Név | Scope |
|---|----|-----------|-------|
| 1 | #324 | Adatbázis: Gamification modell javítások (UserStats, PointsLedger) | Database |
| 2 | #325 | Backend: Új admin_gamification.py router (30+ admin végpont) | Backend |
| 3 | #326 | Backend: GamificationService refaktor (point_rules integráció) | Backend |
| 4 | #327 | Backend: Meglévő PATCH /admin/users/{id}/penalty endpoint javítása | Backend |
| 5 | #328 | Backend: gamification.py endpointok GamificationService-re átállítása | Backend |
| 6 | #329 | Frontend: Gamification menüpont + routing az admin Nuxt felületben | Frontend |
| 7 | #330 | Frontend: Gamification Dashboard oldal (/gamification/index.vue) | Frontend |
| 8 | #331 | Frontend: Pontszabályok + Szintek CRUD oldalak | Frontend |
| 9 | #332 | Frontend: Kitüntetések (Badge) kezelő oldal | Frontend |
| 10 | #333 | Frontend: Szezonok + Versenyek kezelő oldalak | Frontend |
| 11 | #334 | Frontend: Felhasználói statisztikák + büntetés/jutalom oldalak | Frontend |
| 12 | #335 | Frontend: Ranglista admin nézet (leaderboard.vue) | Frontend |
| 13 | #336 | Frontend: Master Config szerkesztő és Rendszerparaméterek oldalak | Frontend |
| 14 | #337 | Frontend: Pontnapló böngésző oldal (ledger.vue) | Frontend |
| 15 | #338 | Tesztelés: Gamification admin E2E tesztek | Backend+Frontend |
### 📂 Létrehozott fájlok
- [`plans/logic_spec_gamification_admin_integration.md`](plans/logic_spec_gamification_admin_integration.md) (373 sor)
### ⚠️ Feltárt kockázatok
1. UserStats modellből hiányzó mezők runtime hibát okozhatnak a meglévő gamification.py endpointokban
2. A PATCH /admin/users/{id}/penalty végpont a `gamification.level` nem létező mezőt módosítja
3. Több gamification.py végpont direktül módosít UserStats mezőket a GamificationService megkerülésével

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Create all 18 Gitea cards for Admin User & Person Management."""
import subprocess, sys
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f"ERROR: {r.stderr}")
return None
return r.stdout.strip()
# Base command
BASE = ["docker", "exec", "roo-helper", "python3", "/scripts/gitea_manager.py"]
MILESTONE = "Epic 10 (Admin UI) Jegyek Létrehozása"
cards = [
# ===== EPIC-1: Backend User Management =====
{
"title": "1.1: GET /admin/users/{user_id} — Felhasználó részletek endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** REST végpont létrehozása egy felhasználó összes adatának lekérésére, beleértve Person, Address, Wallet, TrustProfile, OrganizationMembership adatokat.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Meglévő User, Person, OrganizationMember modellek; Pydantic séma (UserResponse); require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal (3.2), user szerkesztés endpoint (1.2)
### 📝 Elemzés
Hiányzó endpoint: GET /admin/users/{user_id}. SQL logika: LEFT JOIN identity.users, identity.persons, identity.addresses, identity.wallets, identity.user_trust_profiles, marketplace.organization_members. Válasz tartalmazza: User alapadatok, Person adatok lakcímmel, Wallet, TrustProfile, Memberships, SystemCapabilities, OrgCapabilities. Helye: backend/app/api/v1/endpoints/admin.py a meglévő list_users endpoint mellett. Authorization: RequirePermission(permissions=['users:view'])""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.2: PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** REST végpont admin általi felhasználó módosításhoz. Korlátozott mezők: email, is_active, is_vip, preferred_language, region_code, preferred_currency, subscription_plan, subscription_expires_at, max_vehicles, max_garages, scope_level, scope_id, role_id (csak superadmin), custom_permissions. Person almzők: last_name, first_name, phone, mothers data, birth data, identity_docs.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), Pydantic séma (UserUpdate), require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend user szerkesztés funkció
### 📝 Elemzés
PATCH végpont a meglévő admin.py-ban. Ellenőrizni kell a rangot (superadmin vs admin). Audit log bejegyzés minden módosításról. Person adatok módosítása opcionális nested JSON-ben.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.3: GET /admin/users/stats — Felhasználói statisztikák endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Dashboard statisztikák a felhasználókról: total_users, active_users, deleted_users, banned_users, new_users_today/week/month, users_by_role, users_by_plan, users_by_language, users_with_person, users_without_person, registration_trend (napi bontás), active_organizations_count, total_memberships.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** User, OrganizationMember modellek; require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend dashboard (5.1), user lista oldal statisztikai kártyái (3.1)
### 📝 Elemzés
Új GET /admin/users/stats endpoint. SQL aggregációk: COUNT, GROUP BY role/plan/language. Registration trend: DATE(created_at) GROUP BY. JOIN marketplace.organization_members a memberships számhoz.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.4: GET /admin/users/{user_id}/memberships — Szervezeti tagságok endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Egy felhasználó szervezeti tagságainak listázása. Válasz: organization_id, organization_name, role, status, is_permanent, expires_at, invited_email, is_verified.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), OrganizationMember modell
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal Memberships tab (3.2)
### 📝 Elemzés
Új GET /admin/users/{user_id}/memberships endpoint. JOIN marketplace.organization_members ON user_id, majd JOIN fleet.organizations ON organization_id a névhez.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
# ===== EPIC-2: Backend Person Management =====
{
"title": "2.1: GET /admin/persons — Person lista endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person entitások listázása kereséssel, szűréssel. Paraméterek: search (name, phone, birth data), is_ghost, is_active, has_user, is_merged, skip, limit. Válasz: total, items (id, last_name, first_name, phone, birth_date, is_ghost, is_active, merged_into_id, users_count, active_user, address).
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Person modell; require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend Person lista oldal (4.1)
### 📝 Elemzés
Új GET /admin/persons endpoint. SQL: LEFT JOIN identity.users ON person_id (users_count, active_user), LEFT JOIN identity.addresses ON address_id. Szűrés: WHERE is_ghost, is_active, merged_into_id IS NULL/IS NOT NULL.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.2: GET /admin/persons/{person_id} — Person részletek endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Egy Person összes adata, kapcsolódó User-ekkel, szervezeti tagságokkal, tulajdonolt cégekkel, merge history-val.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1), Person modell
- **Kimenet (Mik támaszkodnak rá):** Frontend Person részlet oldal (4.2), Person merge (2.4)
### 📝 Elemzés
GET /admin/persons/{person_id}. Válasz: Person adatok + users lista + address + memberships + owned_business_entities + merged_into (ha merge-elt) + merged_sources (ha ő a cél).""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.3: PATCH /admin/persons/{person_id} — Person szerkesztése",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person adatok admin általi módosítása. Szerkeszthető mezők: last_name, first_name, phone, mothers_last_name, mothers_first_name, birth_place, birth_date, identity_docs, ice_contact, is_active, is_ghost. Address almzők is szerkeszthetők.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), PersonUpdate séma
- **Kimenet (Mik támaszkodnak rá):** Frontend Person szerkesztés funkció
### 📝 Elemzés
PATCH /admin/persons/{person_id}. Audit log bejegyzés. Address rekord frissítése ha változott.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.4: POST /admin/persons/{person_id}/merge — Person merge (duplum kezelés)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Duplum Person-ok összeolvasztása. Body: {source_person_id, keep_source_user}. Logika: (1) Ellenőrizze, hogy mindkét Person létezik és nincs merge-elve. (2) Forrás Person merged_into_id = cél Person ID. (3) Forrás User-ek person_id = cél Person ID. (4) Forrás OrganizationMember person_id = cél Person ID. (5) Audit log.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), Person modell
- **Kimenet (Mik támaszkodnak rá):** Frontend Person merge UI
### 📝 Elemzés
POST /admin/persons/{target_id}/merge. Tranzakcióban: UPDATE identity.users SET person_id = target WHERE person_id = source. UPDATE marketplace.organization_members SET person_id = target WHERE person_id = source. UPDATE identity.persons SET merged_into_id = target WHERE id = source.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
# ===== EPIC-3: Frontend User List & Detail =====
{
"title": "3.1: users/index.vue — Felhasználói lista oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Felhasználói lista oldal létrehozása a frontend admin felületen. Tartalmaz: statisztikai kártyák sor (összes, aktív, törölt, kitiltott, mai regisztrációk), keresőmező (email, név, telefon), szűrők (státusz, szerepkör, előfizetés), táblázat (ID, Email, Név, Szerepkör, Státusz, Csomag, Regisztráció, Nyelv, Műveletek), tömeges műveletek checkbox-szal.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users (meglévő), GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
### 📝 Elemzés
Új fájl: frontend_admin/pages/users/index.vue. API hívás: useFetch('/api/v1/admin/users'). Tömeges művelet: POST /api/v1/admin/users/bulk-action. Statisztikák: GET /api/v1/admin/users/stats. Használja a meglévő api.ts plugint.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "3.2: users/[id]/index.vue — Felhasználó részlet oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Felhasználó részlet oldal tab-okkal: (1) Áttekintés — alapadatok, státusz, szerepkör. (2) Személyes adatok — Person adatok, lakcím. (3) Pénzügyek — Wallet, előfizetés. (4) Tagságok — szervezeti tagságok. (5) Bizalom — Trust score. (6) Tevékenység — naplózott tevékenységek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), GET /admin/users/{user_id}/memberships (1.4)
- **Kimenet (Mik támaszkodnak rá):** Szerkesztés funkció
### 📝 Elemzés
Új fájl: frontend_admin/pages/users/[id]/index.vue. API hívás: useFetch('/api/v1/admin/users/{id}'). Tab komponensek: OverviewTab, PersonTab, FinanceTab, MembershipsTab, TrustTab, ActivityTab.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "3.3: Navigációs menü frissítése — Users & Persons linkek",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Az admin oldalsáv navigációs menüjének frissítése új bejegyzésekkel: 👥 Felhasználók -> /users, ezen belül Felhasználók -> /users/ és Személyek -> /persons/.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
A meglévő sidebar/layout komponens módosítása. Új nav item-ek hozzáadása a megfelelő ikonokkal és linkekkel.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-4: Frontend Person List & Detail =====
{
"title": "4.1: persons/index.vue — Person lista oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person entitások listázása. Kereső: név, telefonszám, születési adatok. Szűrők: ghost státusz, aktív/inaktív, van-e User kapcsolat, merge státusz. Táblázat: ID, Név, Telefon, Születési dátum, User-ek száma, Aktív email, Státusz, Merge státusz, Műveletek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1)
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
### 📝 Elemzés
Új fájl: frontend_admin/pages/persons/index.vue. API hívás: useFetch('/api/v1/admin/persons'). Szűrő paraméterek: search, is_ghost, is_active, has_user, is_merged.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "4.2: persons/[id]/index.vue — Person részlet oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person részlet oldal: személy adatok, kapcsolódó User-ek listája, cím adatok, szervezeti tagságok, merge információ (forrás/cél), tulajdonolt cégek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2)
- **Kimenet (Mik támaszkodnak rá):** Person merge UI
### 📝 Elemzés
Új fájl: frontend_admin/pages/persons/[id]/index.vue. API hívás: useFetch('/api/v1/admin/persons/{id}'). Merge gomb ha a Person nincs merge-elve.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-5: Frontend Statistics & Dashboard =====
{
"title": "5.1: Dashboard statisztika kártyák dinamikus adatokkal",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** A dashboard (index.vue) jelenleg hardcoded statisztikáinak (2,847 Active Users, 12,493 Total Vehicles) lecserélése dinamikus API hívásokra. Felhasználói statisztikák: GET /admin/users/stats. Jármű statisztikák: meglévő asset végpont.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
frontend_admin/pages/index.vue módosítása. useFetch('/api/v1/admin/users/stats') a user statisztikákhoz. useFetch('/api/v1/admin/vehicles/stats') a jármű statisztikákhoz.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "5.2: Felhasználói trend diagram komponens",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Regisztrációs trend diagram a dashboardon. A GET /admin/users/stats registration_trend mezőjéből napi bontású oszlopdiagram megjelenítése az elmúlt 30 napra.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
Chart.js vagy egyszerű SVG/CSS alapú diagram komponens. Használhatja a meglévő dashboard layout-ot.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-6: i18n & Permission Integration =====
{
"title": "6.1: i18n fordítások felvétele (hu/en) — User & Person management",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Új i18n fordítási kulcsok felvétele a hu.json és en.json fájlokba. Kulcsok: users.title, users.search_placeholder, users.filter_role, users.status.active/deleted/banned, users.table.id/email/name/role/status, users.bulk.ban/unban/delete/restore, persons.title, persons.search_placeholder, person.merge.title/confirm/source/target, stats.total_users/active_users/new_today.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
frontend_admin/locales/hu.json és frontend_admin/locales/en.json frissítése. A Nuxt i18n modul automatikusan betölti.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "6.2: RBAC permission check implementáció — User & Person oldalakon",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Minden admin user/person oldalon RBAC permission check implementálása a RequirePermission() függőséggel. Permission-ök: users:view, users:edit, users:ban, users:delete, persons:view, persons:edit, persons:merge.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Meglévő admin_permissions.py RBAC rendszer
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
Backend: RequirePermission() dekorátor hozzáadása az új endpointokhoz. Frontend: hasPermission() kompozíció használata a gombok/show/hide logikához.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
]
for card in cards:
title = card["title"]
body = card["body"]
tags = card["tags"]
cmd = BASE + ["create", title, body, MILESTONE] + tags
print(f"Creating: {title}")
result = run(cmd)
if result:
print(f" -> {result}")
else:
print(f" -> FAILED")
print()

View File

@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security, services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters, billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries, gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, admin_organizations, constants, providers, admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
subscriptions, marketing, admin_permissions, regions, subscriptions, marketing, admin_permissions, regions,
) )
@@ -36,6 +36,8 @@ api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Di
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"]) api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"]) api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"]) api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["Admin Users"])
api_router.include_router(admin_persons.router, prefix="/admin/persons", tags=["Admin Persons"])
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"]) api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"]) api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"]) api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])

View File

@@ -497,6 +497,7 @@ async def list_users(
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"), role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"), is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"), is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
plan_filter: Optional[str] = Query(None, description="Csomag szűrés (pl. FREE, PREMIUM, ENTERPRISE)"),
db: AsyncSession = Depends(deps.get_db), db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user), current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")), _ = Depends(deps.RequirePermission("user:view")),
@@ -580,8 +581,10 @@ async def list_users(
) )
if role: if role:
# Case-insensitive role matching
role_upper = role.upper()
try: try:
role_enum = UserRole(role) role_enum = UserRole(role_upper)
query = query.where(User.role == role_enum) query = query.where(User.role == role_enum)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
@@ -592,6 +595,8 @@ async def list_users(
query = query.where(User.is_active == is_active) query = query.where(User.is_active == is_active)
if is_deleted is not None: if is_deleted is not None:
query = query.where(User.is_deleted == is_deleted) query = query.where(User.is_deleted == is_deleted)
if plan_filter:
query = query.where(User.subscription_plan == plan_filter.upper())
count_query = select(func.count()).select_from(query.subquery()) count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
@@ -625,6 +630,12 @@ async def list_users(
parts.append(str(address.house_number)) parts.append(str(address.house_number))
address_str = ', '.join(parts) if parts else None address_str = ', '.join(parts) if parts else None
# Személy nevének összefűzése
person_name = None
if person:
parts = [p for p in [person.last_name, person.first_name] if p]
person_name = ' '.join(parts) if parts else None
user_list.append({ user_list.append({
"id": u.id, "id": u.id,
"email": u.email, "email": u.email,
@@ -637,6 +648,7 @@ async def list_users(
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": u.created_at.isoformat() if u.created_at else None,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None, "deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
# Person adatok # Person adatok
"person_name": person_name,
"first_name": person.first_name if person else None, "first_name": person.first_name if person else None,
"last_name": person.last_name if person else None, "last_name": person.last_name if person else None,
"phone": person.phone if person else None, "phone": person.phone if person else None,
@@ -656,6 +668,148 @@ async def list_users(
} }
@router.get("/users/stats", tags=["User Management"])
async def get_user_stats(
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
):
"""
Felhasználói statisztikák a Dashboard számára.
Visszaadja az összesítő adatokat: teljes, aktív, törölt, kitiltott felhasználók,
mai/héti/havi új regisztrációk, szerepkör és előfizetés szerinti megoszlás,
nyelvi statisztikák, Person kapcsolatok, szervezeti adatok.
"""
from datetime import date, timedelta
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
month_start = today_start.replace(day=1)
# === ALAP STATISZTIKÁK ===
total_result = await db.execute(select(func.count(User.id)))
total_users = total_result.scalar() or 0
active_result = await db.execute(
select(func.count(User.id)).where(User.is_active == True, User.is_deleted == False)
)
active_users = active_result.scalar() or 0
deleted_result = await db.execute(
select(func.count(User.id)).where(User.is_deleted == True)
)
deleted_users = deleted_result.scalar() or 0
# Banned = is_active=False AND is_deleted=False (deactivated but not deleted)
banned_result = await db.execute(
select(func.count(User.id)).where(User.is_active == False, User.is_deleted == False)
)
banned_users = banned_result.scalar() or 0
# === ÚJ REGISZTRÁCIÓK ===
new_today_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= today_start)
)
new_users_today = new_today_result.scalar() or 0
new_week_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= week_start)
)
new_users_this_week = new_week_result.scalar() or 0
new_month_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= month_start)
)
new_users_this_month = new_month_result.scalar() or 0
# === SZEREPKÖR SZERINTI MEGOSZLÁS ===
role_result = await db.execute(
select(User.role, func.count(User.id)).group_by(User.role)
)
users_by_role = {}
for row in role_result:
role_key = row[0].value if hasattr(row[0], "value") else str(row[0])
users_by_role[role_key] = row[1]
# === ELŐFIZETÉS SZERINTI MEGOSZLÁS ===
plan_result = await db.execute(
select(User.subscription_plan, func.count(User.id)).group_by(User.subscription_plan)
)
users_by_plan = {row[0]: row[1] for row in plan_result}
# === NYELV SZERINTI MEGOSZLÁS ===
lang_result = await db.execute(
select(User.preferred_language, func.count(User.id)).group_by(User.preferred_language)
)
users_by_language = {row[0]: row[1] for row in lang_result}
# === PERSON KAPCSOLATOK ===
with_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.isnot(None))
)
users_with_person = with_person_result.scalar() or 0
users_without_person = total_users - users_with_person
# === SZERVEZETI STATISZTIKÁK ===
from app.models.marketplace.organization import Organization, OrganizationMember
org_count_result = await db.execute(
select(func.count(Organization.id)).where(Organization.is_deleted == False)
)
active_organizations_count = org_count_result.scalar() or 0
memberships_result = await db.execute(
select(func.count(OrganizationMember.id))
)
total_memberships = memberships_result.scalar() or 0
# === JÁRMŰ STATISZTIKÁK ===
from app.models.vehicle.asset import Asset
vehicle_count_result = await db.execute(
select(func.count(Asset.id))
)
total_vehicles = vehicle_count_result.scalar() or 0
# === REGISZTRÁCIÓS TREND (utolsó 30 nap) ===
thirty_days_ago = today_start - timedelta(days=30)
trend_result = await db.execute(
text("""
SELECT DATE(created_at) as reg_date, COUNT(*) as cnt
FROM identity.users
WHERE created_at >= :start_date
GROUP BY DATE(created_at)
ORDER BY reg_date
"""),
{"start_date": thirty_days_ago}
)
registration_trend = [
{"date": row[0].isoformat() if hasattr(row[0], 'isoformat') else str(row[0]), "count": row[1]}
for row in trend_result
]
return {
"total_users": total_users,
"active_users": active_users,
"deleted_users": deleted_users,
"banned_users": banned_users,
"new_users_today": new_users_today,
"new_users_this_week": new_users_this_week,
"new_users_this_month": new_users_this_month,
"users_by_role": users_by_role,
"users_by_plan": users_by_plan,
"users_by_language": users_by_language,
"users_with_person": users_with_person,
"users_without_person": users_without_person,
"total_vehicles": total_vehicles,
"active_organizations_count": active_organizations_count,
"total_memberships": total_memberships,
"registration_trend": registration_trend,
}
@router.post("/users/bulk-action", tags=["User Management"]) @router.post("/users/bulk-action", tags=["User Management"])
async def bulk_user_action( async def bulk_user_action(
request: BulkActionRequest, request: BulkActionRequest,
@@ -732,11 +886,11 @@ async def bulk_user_action(
affected_count += 1 affected_count += 1
audit_log = SecurityAuditLog( audit_log = SecurityAuditLog(
user_id=current_user.id, actor_id=current_user.id,
action=f"bulk_{request.action}", action=f"bulk_{request.action}",
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}", payload_before={},
payload_after={"details": f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}"},
is_critical=(request.action in ("hard_delete", "ban")), is_critical=(request.action in ("hard_delete", "ban")),
ip_address="admin_api"
) )
db.add(audit_log) db.add(audit_log)

View File

@@ -13,7 +13,7 @@ Végpontok:
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -28,6 +28,7 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.fleet.organization import ContactPerson from app.models.fleet.organization import ContactPerson
from app.models.identity import User, Person from app.models.identity import User, Person
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.organization import ( from app.schemas.organization import (
OrganizationMemberCreate, OrganizationMemberCreate,
OrganizationMemberUpdate, OrganizationMemberUpdate,
@@ -45,8 +46,24 @@ router = APIRouter()
class OrgSubscriptionUpdate(BaseModel): class OrgSubscriptionUpdate(BaseModel):
"""Előfizetés módosításának kérése.""" """Előfizetés módosításának kérése.
tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja")
Két üzemmód:
- Mode A (tier_id megadva): Teljes előfizetés váltás (új tier, új subscription sor).
- Mode B (tier_id = None): Csak az add-on (extra_allowances) frissítése a meglévő
aktív előfizetésen. Ekkor a tier_id, expires_at mezők figyelmen kívül maradnak.
P0 ARCHITECTURE SHIFT (Itemized Add-ons):
- Mode B most már ITEMIZÁLT: minden add-on típushoz külön OrganizationSubscription
sort hoz létre/frissít a finance.org_subscriptions táblában, saját valid_until-lal.
- A régi extra_vehicles/extra_branches/extra_users mezők továbbra is támogatottak
a frontend kompatibilitás miatt, de a belső logika itemizált sorokká alakítja őket.
"""
tier_id: Optional[int] = Field(
default=None,
description="Az új SubscriptionTier ID-ja. Ha None (Mode B), csak az add-on-ok "
"frissülnek a meglévő aktív előfizetésen."
)
expires_at: Optional[datetime] = Field( expires_at: Optional[datetime] = Field(
default=None, default=None,
description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). " description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). "
@@ -65,6 +82,22 @@ class OrgSubscriptionUpdate(BaseModel):
default=0, ge=0, default=0, ge=0,
description="Extra dolgozó kvóta a csomag keretein felül (add-on)." description="Extra dolgozó kvóta a csomag keretein felül (add-on)."
) )
# ── P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type ──
extra_vehicles_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Jármű add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
extra_branches_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Telephely add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
extra_users_expires_at: Optional[datetime] = Field(
default=None,
description="Egyedi lejárati dátum az Extra Dolgozó add-on-hoz. "
"Dátum vagy datetime; a backend 23:59:59-re normalizálja."
)
class OrgSubscriptionResponse(BaseModel): class OrgSubscriptionResponse(BaseModel):
@@ -126,10 +159,21 @@ class ContactPersonInfo(BaseModel):
is_primary: bool = False is_primary: bool = False
class AddonInfo(BaseModel):
"""P0 ITEMIZED ADD-ON: Egy add-on sor adatai."""
type: str
quantity: int
subscription_id: Optional[int] = None
valid_until: Optional[str] = None
expires_at: Optional[str] = None
tier_name: Optional[str] = None
class SubscriptionSummary(BaseModel): class SubscriptionSummary(BaseModel):
"""Előfizetés összefoglaló.""" """Előfizetés összefoglaló."""
tier_name: str = "Free/Fallback" tier_name: str = "Free/Fallback"
tier_level: int = 0 tier_level: int = 0
tier_id: Optional[int] = None
valid_from: Optional[str] = None valid_from: Optional[str] = None
expires_at: Optional[str] = None expires_at: Optional[str] = None
is_active: bool = True is_active: bool = True
@@ -140,11 +184,13 @@ class SubscriptionSummary(BaseModel):
user_limit: int = 0 user_limit: int = 0
# ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ── # ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ──
extra_allowances: dict = {} extra_allowances: dict = {}
# ── P0 ITEMIZED ADD-ON: Individual add-on rows ──
addons: List[AddonInfo] = []
class BranchBrief(BaseModel): class BranchBrief(BaseModel):
"""Rövid telephely adatok a frontend számára.""" """Rövid telephely adatok a frontend számára."""
id: int id: str
name: str name: str
city: Optional[str] = None city: Optional[str] = None
is_active: bool = True is_active: bool = True
@@ -200,6 +246,21 @@ class GarageDetailsResponse(BaseModel):
members: List[OrganizationMemberResponse] = [] members: List[OrganizationMemberResponse] = []
# ── P0 ADD-ON UPGRADE: Branches list for utilization display ── # ── P0 ADD-ON UPGRADE: Branches list for utilization display ──
branches: List[BranchBrief] = [] branches: List[BranchBrief] = []
# ── P0 AUDIT TRAIL: Subscription history from audit.audit_logs ──
subscription_history: List["SubscriptionHistoryItem"] = []
class SubscriptionHistoryItem(BaseModel):
"""Egy előfizetés-módosítási esemény az audit naplóból."""
id: int
action: str
admin_user_id: Optional[int] = None
old_data: Optional[Dict[str, Any]] = None
new_data: Optional[Dict[str, Any]] = None
timestamp: Optional[str] = None
# Denormalized display fields
tier_name: Optional[str] = None
changes_summary: Optional[str] = None
# ============================================================================= # =============================================================================
@@ -447,6 +508,105 @@ def _extract_limit(
return default return default
# =============================================================================
# P0 ARCHITECTURE SHIFT: normalize_to_end_of_day() helper
# =============================================================================
def normalize_to_end_of_day(dt: Optional[datetime]) -> Optional[datetime]:
"""
P0 ARCHITECTURE SHIFT: Minden valid_until értéket nap végére (23:59:59) normalizál.
Ez biztosítja, hogy a lejárati dátumok konzisztensen viselkedjenek:
- Ha a user 2026-12-31-et ad meg, az 2026-12-31 23:59:59 UTC lesz.
- Ha a datetime már tartalmaz időt, azt felülírjuk 23:59:59-re.
- Ha None, visszatérünk None-nal.
Támogatja a datetime és date objektumokat is:
- datetime: közvetlenül normalizálja
- date: előbb datetime-vé alakítja (éjjel 00:00:00), majd normalizálja 23:59:59-re
Args:
dt: A normalizálandó datetime vagy date (lehet timezone-aware vagy naive).
Returns:
Optional[datetime]: A nap végére normalizált datetime (timezone-aware UTC),
vagy None ha a bemenet is None.
"""
if dt is None:
return None
# Ha date objektum, alakítsuk datetime-vé
if not isinstance(dt, datetime):
from datetime import date as date_type
if isinstance(dt, date_type):
dt = datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc)
# Ha naive, tegyük UTC-vé
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.replace(hour=23, minute=59, second=59, microsecond=0)
# =============================================================================
# P0 ARCHITECTURE SHIFT: _find_or_create_addon_tier() helper
# =============================================================================
ADDON_TIER_NAMES = {
"extra_vehicles": "Extra Vehicles",
"extra_branches": "Extra Branches",
"extra_users": "Extra Users",
}
async def _find_or_create_addon_tier(
db: AsyncSession,
addon_type: str,
) -> Optional[int]:
"""
P0 ARCHITECTURE SHIFT: Megkeresi vagy létrehozza az add-on SubscriptionTier-t.
Minden add-on típushoz ('extra_vehicles', 'extra_branches', 'extra_users')
léteznie kell egy dedikált SubscriptionTier-nek 'addon' típussal.
Ha nem létezik, automatikusan létrehozza.
Args:
db: Az adatbázis session.
addon_type: Az add-on típus neve (pl. 'extra_vehicles').
Returns:
Optional[int]: A SubscriptionTier ID-ja, vagy None ha nem sikerült.
"""
display_name = ADDON_TIER_NAMES.get(addon_type, addon_type.replace("_", " ").title())
# 1. Próbáljuk megkeresni a meglévő add-on tier-t
stmt = select(SubscriptionTier).where(
SubscriptionTier.type == "addon",
SubscriptionTier.name == addon_type,
)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if tier:
return tier.id
# 2. Ha nem létezik, hozzuk létre
new_tier = SubscriptionTier(
name=addon_type,
type="addon",
tier_level=0,
is_custom=True,
rules={
"display_name": display_name,
"allowances": {f"max_{addon_type.replace('extra_', '')}": 0},
},
feature_capabilities={},
)
db.add(new_tier)
await db.flush()
logger.info(f"P0: Auto-created add-on tier '{addon_type}' (id={new_tier.id})")
return new_tier.id
# ============================================================================= # =============================================================================
# GET /{org_id}/details — Garázs részletes adatai (General Tab) # GET /{org_id}/details — Garázs részletes adatai (General Tab)
# ============================================================================= # =============================================================================
@@ -499,14 +659,22 @@ async def get_organization_details(
# 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1)) # 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1))
# Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk, # Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk,
# majd aggregáljuk a limit értékeket. # majd aggregáljuk a limit értékeket.
now = datetime.utcnow() # P0 CRITICAL FIX: Add-on subscriptions may have valid_until=NULL (indefinite),
# so we must use OR condition: valid_until >= now OR valid_until IS NULL.
# P0 CRITICAL FIX: Use timezone-aware UTC now() because valid_until is
# DateTime(timezone=True). Comparing naive datetime with timezone-aware
# timestamp causes PostgreSQL to return no results.
now = datetime.now(timezone.utc)
sub_stmt = ( sub_stmt = (
select(OrganizationSubscription) select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier)) .options(selectinload(OrganizationSubscription.tier))
.where( .where(
OrganizationSubscription.org_id == org_id, OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True, OrganizationSubscription.is_active == True,
or_(
OrganizationSubscription.valid_until >= now, OrganizationSubscription.valid_until >= now,
OrganizationSubscription.valid_until.is_(None),
),
) )
.order_by(OrganizationSubscription.id.desc()) .order_by(OrganizationSubscription.id.desc())
) )
@@ -514,10 +682,14 @@ async def get_organization_details(
active_subs = sub_result.scalars().all() active_subs = sub_result.scalars().all()
# 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából # 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából
from app.models.vehicle.vehicle import Vehicle # noqa: E402 # P0 CRITICAL FIX: Exclude archived/draft vehicles from quota count.
vehicle_count_stmt = select(func.count(Vehicle.id)).where( # Only count vehicles with status='active' to prevent quota abuse.
Vehicle.organization_id == org_id, # DB schema (vehicle.assets): status values are 'active', 'archived', 'draft'.
Vehicle.is_deleted == False, # 'archived' = soft-deleted, 'draft' = incomplete registration.
from app.models.vehicle import Asset # noqa: E402
vehicle_count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.status == 'active',
) )
vehicle_count_result = await db.execute(vehicle_count_stmt) vehicle_count_result = await db.execute(vehicle_count_stmt)
asset_count = vehicle_count_result.scalar() or 0 asset_count = vehicle_count_result.scalar() or 0
@@ -532,10 +704,10 @@ async def get_organization_details(
branch_rows = branches_result.scalars().all() branch_rows = branches_result.scalars().all()
branches_list = [ branches_list = [
BranchBrief( BranchBrief(
id=b.id, id=str(b.id),
name=b.name, name=b.name,
city=b.city, city=b.city,
is_active=b.is_active, is_active=(b.status == "active") if b.status else True,
) )
for b in branch_rows for b in branch_rows
] ]
@@ -589,7 +761,9 @@ async def get_organization_details(
key=lambda s: s.tier.tier_level if s.tier else 0, key=lambda s: s.tier.tier_level if s.tier else 0,
reverse=True, reverse=True,
) )
primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None) # P0 CRITICAL FIX: primary_sub MUST be a base tier, NEVER an add-on.
# The fallback active_subs[0] could pick an add-on row if no base subs exist.
primary_sub = base_subs_sorted[0] if base_subs_sorted else None
if primary_sub and primary_sub.tier: if primary_sub and primary_sub.tier:
# ── Base limits from primary subscription (using _extract_limit) ── # ── Base limits from primary subscription (using _extract_limit) ──
@@ -610,7 +784,7 @@ async def get_organization_details(
base_users = _extract_limit( base_users = _extract_limit(
primary_rules, primary_rules,
["max_users", "max_members"], ["max_users", "max_members"],
_extract_limit(primary_fc, ["max_users", "max_members"], 0), _extract_limit(primary_fc, ["max_users", "max_members"], 1),
) )
# ── Aggregate extra_allowances from ALL active subscriptions ── # ── Aggregate extra_allowances from ALL active subscriptions ──
@@ -652,9 +826,49 @@ async def get_organization_details(
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0 # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0
asset_limit = max(asset_limit, 1) asset_limit = max(asset_limit, 1)
# ── P0 ITEMIZED ADD-ON: Build add-on list ──
addon_list = []
# 1. Add-ons from dedicated addon-type subscriptions (P0 itemized path)
for addon_sub in addon_subs:
extra = addon_sub.extra_allowances or {}
for addon_type, qty in extra.items():
if qty:
valid_until_str = addon_sub.valid_until.isoformat() if addon_sub.valid_until else None
addon_list.append(AddonInfo(
type=addon_type,
quantity=qty,
subscription_id=addon_sub.id,
valid_until=valid_until_str,
expires_at=valid_until_str,
tier_name=addon_sub.tier.name if addon_sub.tier else None,
))
# 2. Legacy add-ons from base subscription JSONB extra_allowances
# (migration path: old data stored directly on base sub)
existing_addon_types = {a.type for a in addon_list}
for base_sub in base_subs:
legacy_extra = base_sub.extra_allowances or {}
for addon_type, qty in legacy_extra.items():
if qty and addon_type not in existing_addon_types:
valid_until_str = base_sub.valid_until.isoformat() if base_sub.valid_until else None
addon_list.append(AddonInfo(
type=addon_type,
quantity=qty,
subscription_id=base_sub.id,
# Legacy add-ons have no explicit valid_until;
# fall back to base subscription's expires_at
valid_until=valid_until_str,
expires_at=valid_until_str,
tier_name=None,
))
existing_addon_types.add(addon_type)
subscription_summary = SubscriptionSummary( subscription_summary = SubscriptionSummary(
tier_name=primary_sub.tier.name, tier_name=primary_sub.tier.name,
tier_level=primary_sub.tier.tier_level, tier_level=primary_sub.tier.tier_level,
tier_id=primary_sub.tier.id,
valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None, valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None,
expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None, expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None,
is_active=primary_sub.is_active, is_active=primary_sub.is_active,
@@ -663,6 +877,7 @@ async def get_organization_details(
branch_limit=branch_limit, branch_limit=branch_limit,
user_limit=user_limit, user_limit=user_limit,
extra_allowances=aggregated_extra, extra_allowances=aggregated_extra,
addons=addon_list,
) )
elif org.subscription_tier: elif org.subscription_tier:
# Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription) # Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription)
@@ -679,7 +894,7 @@ async def get_organization_details(
) )
base_users = _extract_limit( base_users = _extract_limit(
rules, ["max_users", "max_members"], rules, ["max_users", "max_members"],
_extract_limit(fc, ["max_users", "max_members"], 0), _extract_limit(fc, ["max_users", "max_members"], 1),
) )
# P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path
base_vehicles = max(base_vehicles, 1) base_vehicles = max(base_vehicles, 1)
@@ -694,6 +909,52 @@ async def get_organization_details(
user_limit=base_users, user_limit=base_users,
) )
# ── P0 AUDIT TRAIL: Fetch subscription history from audit.audit_logs ──
subscription_history: List[SubscriptionHistoryItem] = []
try:
audit_stmt = (
select(AuditLog)
.where(
AuditLog.target_type == "organization",
AuditLog.target_id == str(org_id),
AuditLog.action.in_(["SUBSCRIPTION_TIER_CHANGE", "SUBSCRIPTION_ADDON_OVERRIDE"]),
)
.order_by(AuditLog.timestamp.desc())
.limit(50)
)
audit_result = await db.execute(audit_stmt)
audit_rows = audit_result.scalars().all()
for log_entry in audit_rows:
# Build a human-readable changes_summary
changes_summary = None
tier_name = None
if log_entry.action == "SUBSCRIPTION_TIER_CHANGE":
old_tier = (log_entry.old_data or {}).get("tier_id")
new_tier_data = (log_entry.new_data or {})
new_tier = new_tier_data.get("tier_id")
new_tier_name = new_tier_data.get("tier_name", f"Tier #{new_tier}")
tier_name = new_tier_name
changes_summary = f"Csomag váltás: Tier #{old_tier}{new_tier_name}"
elif log_entry.action == "SUBSCRIPTION_ADDON_OVERRIDE":
old_addons = (log_entry.old_data or {}).get("addons", {})
new_addons = (log_entry.new_data or {}).get("addons", {})
changes_summary = f"Kiegészítők módosítása"
tier_name = "Kiegészítők"
subscription_history.append(SubscriptionHistoryItem(
id=log_entry.id,
action=log_entry.action,
admin_user_id=log_entry.user_id,
old_data=log_entry.old_data,
new_data=log_entry.new_data,
timestamp=log_entry.timestamp.isoformat() if log_entry.timestamp else None,
tier_name=tier_name,
changes_summary=changes_summary,
))
except Exception as e:
logger.warning(f"P0 AUDIT TRAIL: Failed to fetch subscription history for org {org_id}: {e}")
# 6. Kapcsolattartó adatok # 6. Kapcsolattartó adatok
primary_contact = None primary_contact = None
if contact and contact.person: if contact and contact.person:
@@ -859,6 +1120,8 @@ async def get_organization_details(
members=members_list, members=members_list,
# ── P0 ADD-ON UPGRADE: Branches list ── # ── P0 ADD-ON UPGRADE: Branches list ──
branches=branches_list, branches=branches_list,
# ── P0 AUDIT TRAIL: Subscription history ──
subscription_history=subscription_history,
) )
@@ -1105,7 +1368,8 @@ async def update_organization(
"/{org_id}/subscription", "/{org_id}/subscription",
summary="Garázs előfizetésének módosítása", summary="Garázs előfizetésének módosítása",
description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. " description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. "
"Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).", "Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal). "
"P0 ITEMIZED ADD-ON: Minden add-on típus külön sorban tárolva.",
) )
async def update_org_subscription( async def update_org_subscription(
org_id: int, org_id: int,
@@ -1117,13 +1381,39 @@ async def update_org_subscription(
""" """
Garázs előfizetésének módosítása. Garázs előfizetésének módosítása.
- Ellenőrzi, hogy a szervezet létezik-e. Két üzemmód:
Mode A (tier_id megadva — Teljes előfizetés váltás):
- Ellenőrzi, hogy a SubscriptionTier létezik-e. - Ellenőrzi, hogy a SubscriptionTier létezik-e.
- Ha van aktív OrganizationSubscription, inaktiválja (is_active=False). - Inaktiválja a meglévő aktív OrganizationSubscription sorokat.
- Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val - Létrehoz egy új Base OrganizationSubscription rekordot a megadott tier_id-val.
és opcionális expires_at dátummal. - Létrehoz külön add-on sorokat minden pozitív extra_* értékhez.
- Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol.
Mode B (tier_id = None — Csak add-on frissítés):
- P0 ITEMIZED ADD-ON: Minden add-on típushoz külön OrganizationSubscription sort
hoz létre vagy frissít, ahelyett hogy egyetlen JSONB-t írna felül.
- Ha egy add-on értéke 0, a meglévő sor inaktiválódik.
- Ha egy add-on értéke > 0, létrejön/aktiválódik egy dedikált sor.
""" """
# ── P0 AUDIT TRAIL: Capture previous subscription state before modification ──
previous_tier_id = None
previous_addons = {}
prev_sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
prev_sub_result = await db.execute(prev_sub_stmt)
prev_sub = prev_sub_result.scalar_one_or_none()
if prev_sub:
previous_tier_id = prev_sub.tier_id
previous_addons = prev_sub.extra_allowances or {}
# 1. Ellenőrizzük a szervezetet # 1. Ellenőrizzük a szervezetet
org_stmt = select(Organization).where(Organization.id == org_id) org_stmt = select(Organization).where(Organization.id == org_id)
org_result = await db.execute(org_stmt) org_result = await db.execute(org_stmt)
@@ -1135,6 +1425,198 @@ async def update_org_subscription(
detail=f"Szervezet nem található ID-vel: {org_id}", detail=f"Szervezet nem található ID-vel: {org_id}",
) )
now = datetime.utcnow()
# ── P0 ITEMIZED ADD-ON: Inner helper to upsert a single add-on row ──
async def _upsert_addon_sub(addon_type: str, quantity: int, expires: Optional[datetime]) -> Optional[int]:
"""Create or deactivate a single add-on subscription row.
Returns the ID of the created/updated row, or None if deactivated/skipped.
P0 CRITICAL SAFETY: When quantity <= 0, we ONLY deactivate existing rows.
We NEVER create new rows with zero quantity. This prevents the frontend
from accidentally creating zombie add-on rows when the form initializes
add-on values to 0 instead of reading from existing subscription data.
"""
if quantity <= 0:
# Deactivate any existing active add-on row for this type
# P0 CRITICAL FIX: Use JSONB .astext.is_not(None) to reliably find rows
# where this addon_type key exists in extra_allowances.
# The previous approach using [addon_type].as_integer().is_not(None) could
# fail if the JSONB structure doesn't match the expected path.
existing_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
# Use JSONB containment: check if extra_allowances contains the key
OrganizationSubscription.extra_allowances[addon_type].astext.is_not(None),
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
existing.is_active = False
logger.info(
f"P0 ITEMIZED: Deactivated add-on row {existing.id} "
f"for {addon_type} (org {org_id})"
)
else:
logger.info(
f"P0 ITEMIZED: No active add-on row found for {addon_type} "
f"(org {org_id}) — skipping deactivation"
)
return None
# quantity > 0: find or create the add-on tier
addon_tier_id = await _find_or_create_addon_tier(db, addon_type)
if addon_tier_id is None:
logger.warning(f"P0 ITEMIZED: Could not find/create add-on tier for {addon_type}")
return None
# Normalize expiration
addon_valid_until = normalize_to_end_of_day(expires) if expires else None
# Check if there's already an active add-on row for this type
existing_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.tier_id == addon_tier_id,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalar_one_or_none()
if existing:
# Update existing row
existing.extra_allowances = {addon_type: quantity}
if addon_valid_until:
existing.valid_until = addon_valid_until
logger.info(
f"P0 ITEMIZED: Updated add-on row {existing.id} "
f"for {addon_type}={quantity} (org {org_id})"
)
return existing.id
else:
# Create new add-on row
new_addon = OrganizationSubscription(
org_id=org_id,
tier_id=addon_tier_id,
valid_from=now,
valid_until=addon_valid_until,
is_active=True,
extra_allowances={addon_type: quantity},
)
db.add(new_addon)
await db.flush()
logger.info(
f"P0 ITEMIZED: Created add-on row for {addon_type}={quantity} (org {org_id})"
)
return new_addon.id
# ── Mode B: tier_id is None — Only add-on update ──
if payload.tier_id is None:
# P0 ITEMIZED ADD-ON: Individual expiration dates per add-on type
addon_expires_map = {
"extra_vehicles": normalize_to_end_of_day(payload.extra_vehicles_expires_at) if payload.extra_vehicles_expires_at else None,
"extra_branches": normalize_to_end_of_day(payload.extra_branches_expires_at) if payload.extra_branches_expires_at else None,
"extra_users": normalize_to_end_of_day(payload.extra_users_expires_at) if payload.extra_users_expires_at else None,
}
# Upsert each add-on type
addon_results = {}
addon_ids = []
for addon_type, quantity in [
("extra_vehicles", payload.extra_vehicles),
("extra_branches", payload.extra_branches),
("extra_users", payload.extra_users),
]:
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires_map[addon_type])
addon_results[addon_type] = row_id
if row_id:
addon_ids.append(row_id)
# ── P0 MIGRATION: Clear legacy extra_allowances on the base subscription ──
# When Mode B creates itemized add-on rows, we MUST clear the legacy
# extra_allowances on the base subscription to prevent double-counting.
# Set to 0 for the add-on types that were explicitly provided in the payload.
base_sub_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.tier_id.isnot(None), # Base subscription has a real tier_id
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
base_sub_result = await db.execute(base_sub_stmt)
base_sub = base_sub_result.scalar_one_or_none()
if base_sub and base_sub.extra_allowances:
legacy_cleanup = {}
for addon_key in ["extra_vehicles", "extra_branches", "extra_users"]:
if getattr(payload, addon_key, 0) > 0:
legacy_cleanup[addon_key] = 0
if legacy_cleanup:
merged = dict(base_sub.extra_allowances)
merged.update(legacy_cleanup)
base_sub.extra_allowances = merged
logger.info(
f"P0 MIGRATION: Cleared legacy extra_allowances on base sub {base_sub.id} "
f"for org {org_id}: {legacy_cleanup}"
)
# ── P0 AUDIT TRAIL: Log add-on override to audit.audit_logs ──
new_addons_data = {
"extra_vehicles": payload.extra_vehicles,
"extra_branches": payload.extra_branches,
"extra_users": payload.extra_users,
}
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="SUBSCRIPTION_ADDON_OVERRIDE",
target_type="organization",
target_id=str(org_id),
old_data={"addons": previous_addons},
new_data={"addons": new_addons_data},
)
db.add(audit_entry)
await db.commit()
# Build response with itemized add-on list
addon_list = []
for addon_type, row_id in addon_results.items():
addon_list.append({
"type": addon_type,
"quantity": getattr(payload, addon_type),
"subscription_id": row_id,
})
logger.info(
f"Admin {current_user.id} ({current_user.email}) updated add-ons for org {org_id}: "
f"extra_vehicles={payload.extra_vehicles}, "
f"extra_branches={payload.extra_branches}, "
f"extra_users={payload.extra_users} "
f"(Mode B, ITEMIZED)"
)
return {
"status": "success",
"message": f"Add-on-ok frissítve a '{org.full_name}' garázs számára.",
"addons": addon_list,
}
# ── Mode A: tier_id is provided — Full subscription change ──
# 2. Ellenőrizzük a SubscriptionTier-t # 2. Ellenőrizzük a SubscriptionTier-t
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id) tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id)
tier_result = await db.execute(tier_stmt) tier_result = await db.execute(tier_stmt)
@@ -1157,7 +1639,6 @@ async def update_org_subscription(
) )
# 4. Számoljuk a lejárati dátumot # 4. Számoljuk a lejárati dátumot
now = datetime.utcnow()
if payload.expires_at: if payload.expires_at:
valid_until = payload.expires_at valid_until = payload.expires_at
else: else:
@@ -1168,31 +1649,73 @@ async def update_org_subscription(
from datetime import timedelta from datetime import timedelta
valid_until = now + timedelta(days=days) valid_until = now + timedelta(days=days)
# 5. P0 ADD-ON: Összeállítjuk az extra_allowances JSONB-t # Normalize base subscription expiration
extra_allowances = {} valid_until = normalize_to_end_of_day(valid_until)
if payload.extra_vehicles > 0:
extra_allowances["extra_vehicles"] = payload.extra_vehicles
if payload.extra_branches > 0:
extra_allowances["extra_branches"] = payload.extra_branches
if payload.extra_users > 0:
extra_allowances["extra_users"] = payload.extra_users
# 6. Létrehozzuk az új subscription rekordot # 5. Létrehozzuk az új Base subscription rekordot (extra_allowances üresen)
new_sub = OrganizationSubscription( new_sub = OrganizationSubscription(
org_id=org_id, org_id=org_id,
tier_id=payload.tier_id, tier_id=payload.tier_id,
valid_from=now, valid_from=now,
valid_until=valid_until, valid_until=valid_until,
is_active=True, is_active=True,
extra_allowances=extra_allowances, extra_allowances={}, # Base tier has no extra allowances
) )
db.add(new_sub) db.add(new_sub)
await db.flush()
# 6. P0 ITEMIZED ADD-ON: Create separate add-on rows for each add-on type
# Each add-on type gets its OWN expiration date from the payload.
# Falls back to the base subscription expires_at if individual date is not set.
addon_list = []
addon_type_config = [
("extra_vehicles", payload.extra_vehicles, payload.extra_vehicles_expires_at),
("extra_branches", payload.extra_branches, payload.extra_branches_expires_at),
("extra_users", payload.extra_users, payload.extra_users_expires_at),
]
for addon_type, quantity, individual_expires in addon_type_config:
# Use individual date if provided, otherwise fall back to base subscription expires_at
addon_expires = normalize_to_end_of_day(individual_expires or payload.expires_at)
row_id = await _upsert_addon_sub(addon_type, quantity, addon_expires)
if row_id:
addon_list.append({
"type": addon_type,
"quantity": quantity,
"subscription_id": row_id,
})
# 7. Frissítjük a Organization denormalizált mezőit is # 7. Frissítjük a Organization denormalizált mezőit is
org.subscription_tier_id = payload.tier_id org.subscription_tier_id = payload.tier_id
org.subscription_expires_at = valid_until org.subscription_expires_at = valid_until
org.subscription_plan = tier.name org.subscription_plan = tier.name
# ── P0 AUDIT TRAIL: Log full subscription tier change to audit.audit_logs ──
new_tier_data = {
"tier_id": payload.tier_id,
"tier_name": tier.name,
"valid_until": valid_until.isoformat() if valid_until else None,
"addons": {
"extra_vehicles": payload.extra_vehicles,
"extra_branches": payload.extra_branches,
"extra_users": payload.extra_users,
},
}
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="SUBSCRIPTION_TIER_CHANGE",
target_type="organization",
target_id=str(org_id),
old_data={
"tier_id": previous_tier_id,
"addons": previous_addons,
},
new_data=new_tier_data,
)
db.add(audit_entry)
await db.commit() await db.commit()
await db.refresh(new_sub) await db.refresh(new_sub)
@@ -1200,7 +1723,7 @@ async def update_org_subscription(
f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: " f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: "
f"tier_id={payload.tier_id}, tier_name={tier.name}, " f"tier_id={payload.tier_id}, tier_name={tier.name}, "
f"valid_until={valid_until.isoformat()}, " f"valid_until={valid_until.isoformat()}, "
f"extra_allowances={extra_allowances}" f"addons={addon_list}"
) )
return { return {
@@ -1214,8 +1737,9 @@ async def update_org_subscription(
"valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None, "valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None,
"valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None, "valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None,
"is_active": new_sub.is_active, "is_active": new_sub.is_active,
"extra_allowances": extra_allowances, "extra_allowances": {},
}, },
"addons": addon_list,
} }
@@ -1562,17 +2086,22 @@ async def get_organization_vehicles(
# - közvetlenül a tulajdonában van (owner_org_id), VAGY # - közvetlenül a tulajdonában van (owner_org_id), VAGY
# - jelenleg oda van rendelve (current_organization_id) # - jelenleg oda van rendelve (current_organization_id)
# NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak. # NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak.
#
# P0 CRITICAL FIX: Filter by status == 'active' to exclude archived/draft vehicles
# from the fleet list AND from the quota count. Without this filter, archived
# vehicles still appear in the fleet tab and consume quota slots.
ownership_filter = or_( ownership_filter = or_(
Asset.owner_org_id == org_id, Asset.owner_org_id == org_id,
Asset.current_organization_id == org_id, Asset.current_organization_id == org_id,
) )
active_filter = Asset.status == 'active'
# P0 HOTFIX: Asset.branch relationship nem létezik a modellben. # P0 HOTFIX: Asset.branch relationship nem létezik a modellben.
# Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez. # Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez.
vehicle_stmt = ( vehicle_stmt = (
select(Asset, Branch.name.label("branch_name")) select(Asset, Branch.name.label("branch_name"))
.outerjoin(Branch, Asset.branch_id == Branch.id) .outerjoin(Branch, Asset.branch_id == Branch.id)
.where(ownership_filter) .where(ownership_filter, active_filter)
.order_by(Asset.created_at.desc()) .order_by(Asset.created_at.desc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
@@ -1580,9 +2109,9 @@ async def get_organization_vehicles(
vehicle_result = await db.execute(vehicle_stmt) vehicle_result = await db.execute(vehicle_stmt)
rows = vehicle_result.all() rows = vehicle_result.all()
# Teljes számolás # Teljes számolás (also filtered by active status)
count_stmt = select(func.count()).select_from( count_stmt = select(func.count()).select_from(
select(Asset).where(ownership_filter).subquery() select(Asset).where(ownership_filter, active_filter).subquery()
) )
count_result = await db.execute(count_stmt) count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0 total = count_result.scalar() or 0

View File

@@ -229,6 +229,7 @@ async def create_package(
tier = SubscriptionTier( tier = SubscriptionTier(
name=payload.name, name=payload.name,
type=payload.type,
rules=rules_dict, rules=rules_dict,
is_custom=payload.is_custom, is_custom=payload.is_custom,
tier_level=payload.tier_level, tier_level=payload.tier_level,
@@ -309,6 +310,9 @@ async def update_package(
) )
tier.name = update_data["name"] tier.name = update_data["name"]
if "type" in update_data and update_data["type"] is not None:
tier.type = update_data["type"]
if "rules" in update_data and update_data["rules"] is not None: if "rules" in update_data and update_data["rules"] is not None:
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ── # ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
# Previously: tier.rules = new_rules (wholesale replacement) # Previously: tier.rules = new_rules (wholesale replacement)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
"""
👤 Admin Users API
Végpontok:
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
van regisztrálva, és a /users, /users/bulk-action útvonalakat kezeli.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select, func, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.api import deps
from app.db.session import get_db
from app.models.identity import User, Person, UserRole
from app.models.identity.address import Address
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.user import UserResponse
from app.schemas.organization import OrganizationMemberResponse
logger = logging.getLogger("admin-users")
router = APIRouter()
# =============================================================================
# Pydantic Schemas
# =============================================================================
class RegistrationTrendItem(BaseModel):
"""Napi regisztrációs trend elem."""
date: str = Field(..., description="Dátum (YYYY-MM-DD)")
count: int = Field(..., description="Regisztrációk száma")
class UserStatsResponse(BaseModel):
"""Felhasználói statisztikák a dashboard számára."""
total_users: int = Field(..., description="Összes felhasználó")
active_users: int = Field(..., description="Aktív felhasználók")
deleted_users: int = Field(..., description="Törölt felhasználók")
banned_users: int = Field(..., description="Kitiltott felhasználók")
new_users_today: int = Field(..., description="Ma regisztrált felhasználók")
new_users_this_week: int = Field(..., description="Ezen a héten regisztrált felhasználók")
new_users_this_month: int = Field(..., description="Ebben a hónapban regisztrált felhasználók")
users_by_role: Dict[str, int] = Field(..., description="Felhasználók szerepkör szerint")
users_by_plan: Dict[str, int] = Field(..., description="Felhasználók előfizetési csomag szerint")
users_by_language: Dict[str, int] = Field(..., description="Felhasználók nyelv szerint")
users_with_person: int = Field(..., description="Person rekorddal rendelkező felhasználók")
users_without_person: int = Field(..., description="Person rekord nélküli felhasználók")
registration_trend: List[RegistrationTrendItem] = Field(
default_factory=list, description="Regisztrációs trend (utolsó 30 nap)"
)
active_organizations_count: int = Field(..., description="Aktív szervezetek száma")
total_memberships: int = Field(..., description="Összes szervezeti tagság")
class AdminUserUpdate(BaseModel):
"""Admin által szerkeszthető felhasználói mezők.
Csak a megadott mezők módosíthatók admin felületről.
A role_id módosítása csak SUPERADMIN számára engedélyezett.
"""
email: Optional[EmailStr] = Field(default=None, description="Email cím (csak admin/superadmin)")
is_active: Optional[bool] = Field(default=None, description="Aktív státusz (letiltás/feloldás)")
is_vip: Optional[bool] = Field(default=None, description="VIP státusz")
preferred_language: Optional[str] = Field(default=None, max_length=5, description="Nyelv")
region_code: Optional[str] = Field(default=None, max_length=5, description="Régió kód")
preferred_currency: Optional[str] = Field(default=None, max_length=3, description="Pénznem")
subscription_plan: Optional[str] = Field(default=None, max_length=30, description="Előfizetési csomag")
subscription_expires_at: Optional[datetime] = Field(default=None, description="Előfizetés lejárata")
scope_level: Optional[str] = Field(default=None, max_length=30, description="Scope szint")
scope_id: Optional[str] = Field(default=None, max_length=50, description="Scope azonosító")
role_id: Optional[int] = Field(default=None, description="RBAC szerepkör ID (csak superadmin)")
custom_permissions: Optional[Dict[str, Any]] = Field(default=None, description="Egyedi jogosultságok")
# Person almzők
person: Optional["AdminPersonUpdate"] = Field(default=None, description="Személyes adatok")
class AdminPersonUpdate(BaseModel):
"""Admin által szerkeszthető személyes adatok."""
last_name: Optional[str] = Field(default=None, description="Vezetéknév")
first_name: Optional[str] = Field(default=None, description="Keresztnév")
phone: Optional[str] = Field(default=None, description="Telefonszám")
mothers_last_name: Optional[str] = Field(default=None, description="Anyja születési vezetékneve")
mothers_first_name: Optional[str] = Field(default=None, description="Anyja születési keresztneve")
birth_place: Optional[str] = Field(default=None, description="Születési hely")
birth_date: Optional[datetime] = Field(default=None, description="Születési dátum")
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
# =============================================================================
# Endpoints
# =============================================================================
@router.get(
"/stats",
response_model=UserStatsResponse,
summary="Felhasználói statisztikák lekérése",
description=(
"Visszaadja a felhasználók összesített statisztikáit a dashboard számára, "
"beleértve a szerepkör, előfizetési csomag és nyelv szerinti megoszlást, "
"valamint a regisztrációs trendet. Csak admin/staff jogosultsággal érhető el."
),
)
async def get_user_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> UserStatsResponse:
"""
GET /admin/users/stats
Összesített felhasználói statisztikák a dashboard számára.
Az összes lekérdezés egyetlen tranzakcióban fut le.
"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
month_start = today_start.replace(day=1)
# ── 1. Alap statisztikák ──
total_users_result = await db.execute(
select(func.count(User.id))
)
total_users = total_users_result.scalar() or 0
active_users_result = await db.execute(
select(func.count(User.id)).where(User.is_active.is_(True), User.is_deleted.is_(False))
)
active_users = active_users_result.scalar() or 0
deleted_users_result = await db.execute(
select(func.count(User.id)).where(User.is_deleted.is_(True))
)
deleted_users = deleted_users_result.scalar() or 0
banned_users_result = await db.execute(
select(func.count(User.id)).where(
User.is_active.is_(False),
User.is_deleted.is_(False),
)
)
banned_users = banned_users_result.scalar() or 0
# ── 2. Új felhasználók ──
new_today_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= today_start)
)
new_users_today = new_today_result.scalar() or 0
new_week_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= week_start)
)
new_users_this_week = new_week_result.scalar() or 0
new_month_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= month_start)
)
new_users_this_month = new_month_result.scalar() or 0
# ── 3. Felhasználók szerepkör szerint ──
role_rows = await db.execute(
select(User.role, func.count(User.id).label("cnt"))
.group_by(User.role)
)
users_by_role: Dict[str, int] = {}
for row in role_rows:
role_name = row.role.value if hasattr(row.role, 'value') else str(row.role)
users_by_role[role_name.lower()] = row.cnt
# ── 4. Felhasználók előfizetési csomag szerint ──
plan_rows = await db.execute(
select(User.subscription_plan, func.count(User.id).label("cnt"))
.group_by(User.subscription_plan)
)
users_by_plan: Dict[str, int] = {}
for row in plan_rows:
users_by_plan[row.subscription_plan.lower()] = row.cnt
# ── 5. Felhasználók nyelv szerint ──
lang_rows = await db.execute(
select(User.preferred_language, func.count(User.id).label("cnt"))
.group_by(User.preferred_language)
)
users_by_language: Dict[str, int] = {}
for row in lang_rows:
users_by_language[row.preferred_language] = row.cnt
# ── 6. Person kapcsolat ──
with_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.isnot(None))
)
users_with_person = with_person_result.scalar() or 0
without_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.is_(None))
)
users_without_person = without_person_result.scalar() or 0
# ── 7. Regisztrációs trend (utolsó 30 nap) ──
thirty_days_ago = today_start - timedelta(days=30)
day_trunc = func.date_trunc("day", User.created_at)
trend_rows = await db.execute(
select(
day_trunc.label("day"),
func.count(User.id).label("cnt"),
)
.where(User.created_at >= thirty_days_ago)
.group_by(day_trunc)
.order_by(day_trunc)
)
registration_trend: List[RegistrationTrendItem] = []
for row in trend_rows:
day_str = row.day.strftime("%Y-%m-%d") if row.day else ""
registration_trend.append(RegistrationTrendItem(date=day_str, count=row.cnt))
# ── 8. Szervezeti statisztikák ──
active_orgs_result = await db.execute(
select(func.count(Organization.id))
.where(Organization.is_anonymized.is_(False))
)
active_organizations_count = active_orgs_result.scalar() or 0
total_memberships_result = await db.execute(
select(func.count(OrganizationMember.id))
)
total_memberships = total_memberships_result.scalar() or 0
return UserStatsResponse(
total_users=total_users,
active_users=active_users,
deleted_users=deleted_users,
banned_users=banned_users,
new_users_today=new_users_today,
new_users_this_week=new_users_this_week,
new_users_this_month=new_users_this_month,
users_by_role=users_by_role,
users_by_plan=users_by_plan,
users_by_language=users_by_language,
users_with_person=users_with_person,
users_without_person=users_without_person,
registration_trend=registration_trend,
active_organizations_count=active_organizations_count,
total_memberships=total_memberships,
)
@router.get(
"/{user_id}",
response_model=UserResponse,
summary="Felhasználó részletes adatainak lekérése",
description=(
"Visszaadja egy felhasználó összes adatát, beleértve a Person rekordot "
"és a beágyazott Address adatokat. Csak admin/staff jogosultsággal érhető el."
),
)
async def get_admin_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> Dict[str, Any]:
"""
GET /admin/users/{user_id}
Lekérdezi a felhasználót a megadott ID alapján, eager loading-gal betölti
a Person és Address kapcsolatokat, majd visszaadja a teljes UserResponse-t.
Args:
user_id: A lekérdezni kívánt felhasználó ID-ja.
Returns:
UserResponse séma szerinti felhasználói adatok.
Raises:
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
"""
stmt = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található.",
)
# Reuse the existing _build_user_response helper from users.py
from app.api.v1.endpoints.users import _build_user_response
response_data = await _build_user_response(user, db=db)
return UserResponse.model_validate(response_data)
@router.patch(
"/{user_id}",
response_model=Dict[str, Any],
summary="Felhasználó szerkesztése",
description=(
"Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók. "
"A role_id módosítása csak SUPERADMIN számára engedélyezett. "
"Szuperadmin felhasználó nem módosítható."
),
)
async def update_admin_user(
user_id: int,
update_data: AdminUserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:edit")),
) -> Dict[str, Any]:
"""
PATCH /admin/users/{user_id}
Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók.
A role_id módosítása csak SUPERADMIN számára engedélyezett.
Szuperadmin felhasználó nem módosítható.
Args:
user_id: A módosítandó felhasználó ID-ja.
update_data: A módosítandó mezők.
Returns:
UserResponse séma szerinti frissített felhasználói adatok.
Raises:
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
HTTPException 403: Ha a módosítás nem engedélyezett.
"""
# ── 1. Lekérdezzük a felhasználót ──
stmt = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található.",
)
# ── 2. Biztonsági ellenőrzések ──
# Szuperadmin felhasználó nem módosítható (még másik superadmin által sem)
if user.role == UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Szuperadmin felhasználó nem módosítható.",
)
# role_id módosítása csak SUPERADMIN számára engedélyezett
if update_data.role_id is not None and current_user.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak SUPERADMIN módosíthatja a szerepkört.",
)
# ── 3. Összegyűjtjük a változásokat audit loghoz ──
old_data: Dict[str, Any] = {}
new_data: Dict[str, Any] = {}
# ── 4. User mezők frissítése ──
update_fields: Dict[str, Any] = {}
if update_data.email is not None:
# Email egyediség ellenőrzés
existing = await db.execute(
select(User).where(User.email == update_data.email, User.id != user_id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ez az email cím már használatban van.",
)
old_data["email"] = user.email
new_data["email"] = update_data.email
update_fields["email"] = update_data.email
if update_data.is_active is not None:
old_data["is_active"] = user.is_active
new_data["is_active"] = update_data.is_active
update_fields["is_active"] = update_data.is_active
if update_data.is_vip is not None:
old_data["is_vip"] = user.is_vip
new_data["is_vip"] = update_data.is_vip
update_fields["is_vip"] = update_data.is_vip
if update_data.preferred_language is not None:
old_data["preferred_language"] = user.preferred_language
new_data["preferred_language"] = update_data.preferred_language
update_fields["preferred_language"] = update_data.preferred_language
if update_data.region_code is not None:
old_data["region_code"] = user.region_code
new_data["region_code"] = update_data.region_code
update_fields["region_code"] = update_data.region_code
if update_data.preferred_currency is not None:
old_data["preferred_currency"] = user.preferred_currency
new_data["preferred_currency"] = update_data.preferred_currency
update_fields["preferred_currency"] = update_data.preferred_currency
if update_data.subscription_plan is not None:
old_data["subscription_plan"] = user.subscription_plan
new_data["subscription_plan"] = update_data.subscription_plan
update_fields["subscription_plan"] = update_data.subscription_plan
if update_data.subscription_expires_at is not None:
old_data["subscription_expires_at"] = (
user.subscription_expires_at.isoformat() if user.subscription_expires_at else None
)
new_data["subscription_expires_at"] = update_data.subscription_expires_at.isoformat()
update_fields["subscription_expires_at"] = update_data.subscription_expires_at
if update_data.scope_level is not None:
old_data["scope_level"] = user.scope_level
new_data["scope_level"] = update_data.scope_level
update_fields["scope_level"] = update_data.scope_level
if update_data.scope_id is not None:
old_data["scope_id"] = user.scope_id
new_data["scope_id"] = update_data.scope_id
update_fields["scope_id"] = update_data.scope_id
if update_data.role_id is not None:
old_data["role_id"] = user.role_id
new_data["role_id"] = update_data.role_id
update_fields["role_id"] = update_data.role_id
if update_data.custom_permissions is not None:
old_data["custom_permissions"] = user.custom_permissions
new_data["custom_permissions"] = update_data.custom_permissions
update_fields["custom_permissions"] = update_data.custom_permissions
# ── 5. Person mezők frissítése ──
person_updated = False
if update_data.person is not None and user.person is not None:
person = user.person
person_update_fields: Dict[str, Any] = {}
if update_data.person.last_name is not None:
old_data["person.last_name"] = person.last_name
new_data["person.last_name"] = update_data.person.last_name
person_update_fields["last_name"] = update_data.person.last_name
if update_data.person.first_name is not None:
old_data["person.first_name"] = person.first_name
new_data["person.first_name"] = update_data.person.first_name
person_update_fields["first_name"] = update_data.person.first_name
if update_data.person.phone is not None:
old_data["person.phone"] = person.phone
new_data["person.phone"] = update_data.person.phone
person_update_fields["phone"] = update_data.person.phone
if update_data.person.mothers_last_name is not None:
old_data["person.mothers_last_name"] = person.mothers_last_name
new_data["person.mothers_last_name"] = update_data.person.mothers_last_name
person_update_fields["mothers_last_name"] = update_data.person.mothers_last_name
if update_data.person.mothers_first_name is not None:
old_data["person.mothers_first_name"] = person.mothers_first_name
new_data["person.mothers_first_name"] = update_data.person.mothers_first_name
person_update_fields["mothers_first_name"] = update_data.person.mothers_first_name
if update_data.person.birth_place is not None:
old_data["person.birth_place"] = person.birth_place
new_data["person.birth_place"] = update_data.person.birth_place
person_update_fields["birth_place"] = update_data.person.birth_place
if update_data.person.birth_date is not None:
old_data["person.birth_date"] = (
person.birth_date.isoformat() if person.birth_date else None
)
new_data["person.birth_date"] = update_data.person.birth_date.isoformat()
person_update_fields["birth_date"] = update_data.person.birth_date
if update_data.person.identity_docs is not None:
old_data["person.identity_docs"] = person.identity_docs
new_data["person.identity_docs"] = update_data.person.identity_docs
person_update_fields["identity_docs"] = update_data.person.identity_docs
if person_update_fields:
for field, value in person_update_fields.items():
setattr(person, field, value)
person.updated_at = datetime.now(timezone.utc)
person_updated = True
# ── 6. Ha nincs változás, dobjunk hibát ──
if not update_fields and not person_updated:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nincs módosítandó mező.",
)
# ── 7. Végrehajtjuk a frissítést ──
if update_fields:
stmt_update = (
sa_update(User)
.where(User.id == user_id)
.values(**update_fields)
)
await db.execute(stmt_update)
await db.commit()
# ── 8. Audit log ──
try:
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="ADMIN_USER_UPDATE",
target_type="user",
target_id=str(user_id),
old_data=old_data if old_data else None,
new_data=new_data if new_data else None,
)
db.add(audit_entry)
await db.commit()
except Exception as e:
logger.warning(f"Failed to write audit log for user update {user_id}: {e}")
await db.rollback()
# ── 9. Visszaadjuk a frissített adatokat ──
# Újratöltjük a frissített adatokat
stmt_refresh = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result_refresh = await db.execute(stmt_refresh)
updated_user = result_refresh.unique().scalar_one_or_none()
from app.api.v1.endpoints.users import _build_user_response
response_data = await _build_user_response(updated_user, db=db)
# Kibővítjük a hiányzó mezőkkel, amik a User modellen léteznek,
# de a _build_user_response dict-ből és a UserResponse sémából hiányoznak
response_data["is_vip"] = updated_user.is_vip
response_data["preferred_currency"] = updated_user.preferred_currency
response_data["custom_permissions"] = updated_user.custom_permissions
response_data["subscription_expires_at"] = (
updated_user.subscription_expires_at.isoformat() if updated_user.subscription_expires_at else None
)
response_data["scope_level"] = updated_user.scope_level or "individual"
response_data["scope_id"] = str(updated_user.scope_id) if updated_user.scope_id else None
return response_data
# =============================================================================
# Memberships Endpoint
# =============================================================================
class UserMembershipResponse(BaseModel):
"""Egy szervezeti tagság adatai a felhasználó nézetében."""
id: int
organization_id: int
organization_name: str = Field(..., description="Szervezet neve")
organization_display_name: Optional[str] = Field(default=None, description="Szervezet megjelenítési neve")
role: str = Field(..., description="Szerepkör a szervezetben")
status: str = Field(default="active", description="Tagság státusza")
is_verified: bool = Field(default=False, description="Ellenőrzött tagság")
is_permanent: bool = Field(default=False, description="Állandó tagság")
joined_at: Optional[str] = Field(default=None, description="Csatlakozás dátuma")
@router.get(
"/{user_id}/memberships",
response_model=List[UserMembershipResponse],
summary="Felhasználó szervezeti tagságai",
description=(
"Visszaadja egy felhasználó összes szervezeti tagságát. "
"Csak admin/staff jogosultsággal érhető el."
),
)
async def get_user_memberships(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> List[UserMembershipResponse]:
"""
GET /admin/users/{user_id}/memberships
Lekérdezi a felhasználó összes szervezeti tagságát.
"""
stmt = (
select(OrganizationMember)
.where(OrganizationMember.user_id == user_id)
.options(joinedload(OrganizationMember.organization))
)
result = await db.execute(stmt)
memberships = result.unique().scalars().all()
membership_list = []
for m in memberships:
membership_list.append(UserMembershipResponse(
id=m.id,
organization_id=m.organization_id,
organization_name=m.organization.name if m.organization else "Ismeretlen",
organization_display_name=m.organization.display_name if m.organization else None,
role=m.role,
status=m.status,
is_verified=m.is_verified,
is_permanent=m.is_permanent,
joined_at=m.joined_at.isoformat() if m.joined_at else None,
))
return membership_list

View File

@@ -46,9 +46,10 @@ class PricingModel(BaseModel):
class AllowancesModel(BaseModel): class AllowancesModel(BaseModel):
"""Korlátok és keretek a csomagban.""" """Korlátok és keretek a csomagban."""
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám") max_vehicles: int = Field(default=0, ge=0, description="Maximális járműszám")
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma") max_garages: int = Field(default=0, ge=0, description="Maximális garázsok száma")
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma") max_users: int = Field(default=0, ge=0, description="Maximális dolgozók/felhasználók száma (0 = nincs korlátozva)")
monthly_free_credits: int = Field(default=0, ge=0, description="Havi ingyenes kreditek száma")
class DurationModel(BaseModel): class DurationModel(BaseModel):
@@ -100,7 +101,7 @@ class SubscriptionRulesModel(BaseModel):
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days) ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color) marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
""" """
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa") type: Optional[str] = Field(default=None, pattern=r"^(private|corporate|addon)$", description="Csomag típusa (private/corporate/addon)")
display_name: Optional[str] = Field( display_name: Optional[str] = Field(
default=None, default=None,
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')", description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
@@ -141,9 +142,11 @@ class SubscriptionRulesModel(BaseModel):
@field_validator("type") @field_validator("type")
@classmethod @classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: Optional[str]) -> Optional[str]:
if v.lower() not in ("private", "corporate"): if v is None:
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.") return v
if v.lower() not in ("private", "corporate", "addon"):
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
return v.lower() return v.lower()
@@ -184,6 +187,7 @@ class PublicSubscriptionTierResponse(BaseModel):
class SubscriptionTierCreate(BaseModel): class SubscriptionTierCreate(BaseModel):
"""Új előfizetési csomag létrehozása (POST /).""" """Új előfizetési csomag létrehozása (POST /)."""
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)") name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
type: Optional[str] = Field(default="base", description="Csomag típusa: 'base' (alap) vagy 'addon' (kiegészítő)")
rules: SubscriptionRulesModel rules: SubscriptionRulesModel
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e") is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise") tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
@@ -202,6 +206,7 @@ class SubscriptionTierCreate(BaseModel):
class SubscriptionTierUpdate(BaseModel): class SubscriptionTierUpdate(BaseModel):
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id}).""" """Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév") name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
type: Optional[str] = Field(default=None, description="Csomag típusa: 'base' vagy 'addon'")
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB") rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés") is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level") tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")

View File

@@ -670,9 +670,13 @@ class AssetService:
A művelet: A művelet:
1. Frissíti a current_mileage értékét a megadott final_mileage-ra 1. Frissíti a current_mileage értékét a megadott final_mileage-ra
2. Állítja: status = 'archived' 2. Állítja: status = 'archived'
3. Nullázza az owner_person_id és owner_org_id mezőket 3. Nullázza az owner_person_id, owner_org_id és current_organization_id mezőket
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be 4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba 5. Naplózza a biztonsági auditba
P0 CRITICAL FIX: current_organization_id is also cleared so the vehicle
no longer counts toward the org's active vehicle quota. The previous
value is preserved in archive_info for audit/historical purposes.
""" """
from app.models.identity import User from app.models.identity import User
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -712,14 +716,19 @@ class AssetService:
'archived_at': now_iso, 'archived_at': now_iso,
'previous_owner_person_id': asset.owner_person_id, 'previous_owner_person_id': asset.owner_person_id,
'previous_owner_org_id': asset.owner_org_id, 'previous_owner_org_id': asset.owner_org_id,
'previous_current_organization_id': asset.current_organization_id,
'final_mileage': final_mileage, 'final_mileage': final_mileage,
} }
# 5. Frissítjük a jármű adatait # 5. Frissítjük a jármű adatait
# P0 CRITICAL FIX: Clear current_organization_id so the vehicle no longer
# counts toward the org's active vehicle quota. The previous value is
# preserved in archive_info above for historical audit purposes.
asset.current_mileage = final_mileage asset.current_mileage = final_mileage
asset.status = "archived" asset.status = "archived"
asset.owner_person_id = None asset.owner_person_id = None
asset.owner_org_id = None asset.owner_org_id = None
asset.current_organization_id = None
asset.individual_equipment = equipment asset.individual_equipment = equipment
asset.updated_at = datetime.now(timezone.utc) asset.updated_at = datetime.now(timezone.utc)

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
Test script for PATCH /admin/users/{user_id} endpoint.
Uses form-data for OAuth2 login, then tests the admin user update endpoint.
"""
import httpx
import asyncio
import json
import sys
BASE_URL = "http://sf_api:8000/api/v1"
async def test_admin_user_patch():
async with httpx.AsyncClient(base_url=BASE_URL) as client:
# Step 1: Login as admin using form data (OAuth2PasswordRequestForm)
print("=" * 60)
print("STEP 1: Login as admin")
print("=" * 60)
login_data = {
"username": "admin@profibot.hu",
"password": "Admin123!",
"remember_me": "true"
}
r = await client.post("/auth/login", data=login_data)
print(f"Status: {r.status_code}")
if r.status_code != 200:
print(f"Login failed: {r.text}")
return False
token_data = r.json()
access_token = token_data.get("access_token")
print(f"Token received: {access_token[:50]}...")
headers = {"Authorization": f"Bearer {access_token}"}
# Step 2: Try to find a non-superadmin user
print("\n" + "=" * 60)
print("STEP 2: Find a non-superadmin user")
print("=" * 60)
# Try user IDs 2, 3, 4, etc. until we find a regular user
test_user_id = None
for uid in [2, 3, 4, 5]:
r = await client.get(f"/admin/users/{uid}", headers=headers)
if r.status_code == 200:
user_data = r.json()
role = user_data.get("role", "")
print(f" User ID={uid}: email={user_data.get('email')}, role={role}")
if role != "superadmin":
test_user_id = uid
break
else:
print(f" User ID={uid}: Status={r.status_code}")
if test_user_id is None:
print("No non-superadmin user found. Using user ID 2 as fallback.")
test_user_id = 2
# Get the user details
r = await client.get(f"/admin/users/{test_user_id}", headers=headers)
if r.status_code != 200:
print(f"Failed to get user {test_user_id}: {r.text}")
return False
user_data = r.json()
user_id = user_data.get("id")
user_email = user_data.get("email")
user_role = user_data.get("role")
print(f"\nUsing test user: ID={user_id}, Email={user_email}, Role={user_role}")
# Step 3: Get user details before update
print("\n" + "=" * 60)
print(f"STEP 3: User details (before update)")
print("=" * 60)
print(f" is_vip: {user_data.get('is_vip')}")
print(f" preferred_language: {user_data.get('preferred_language')}")
print(f" is_active: {user_data.get('is_active')}")
print(f" region_code: {user_data.get('region_code')}")
print(f" preferred_currency: {user_data.get('preferred_currency')}")
print(f" subscription_plan: {user_data.get('subscription_plan')}")
# Step 4: PATCH the user
print("\n" + "=" * 60)
print(f"STEP 4: PATCH user {user_id} - update fields")
print("=" * 60)
patch_data = {
"is_vip": True,
"preferred_language": "en",
"region_code": "HU",
"preferred_currency": "HUF",
}
r = await client.patch(f"/admin/users/{user_id}", json=patch_data, headers=headers)
print(f"Status: {r.status_code}")
if r.status_code != 200:
print(f"PATCH failed: {r.text}")
return False
updated_user = r.json()
print(f"User after update:")
print(f" is_vip: {updated_user.get('is_vip')}")
print(f" preferred_language: {updated_user.get('preferred_language')}")
print(f" region_code: {updated_user.get('region_code')}")
print(f" preferred_currency: {updated_user.get('preferred_currency')}")
# Verify the changes
assert updated_user.get("is_vip") == True, "is_vip not updated"
assert updated_user.get("preferred_language") == "en", "preferred_language not updated"
assert updated_user.get("region_code") == "HU", "region_code not updated"
assert updated_user.get("preferred_currency") == "HUF", "preferred_currency not updated"
print("\n✅ All basic field updates verified!")
# Step 5: Test person sub-object update
print("\n" + "=" * 60)
print(f"STEP 5: PATCH user {user_id} - update person fields")
print("=" * 60)
patch_person_data = {
"person": {
"last_name": "Updated",
"first_name": "User",
"phone": "+36123456789"
}
}
r = await client.patch(f"/admin/users/{user_id}", json=patch_person_data, headers=headers)
print(f"Status: {r.status_code}")
if r.status_code != 200:
print(f"PATCH person failed: {r.text}")
else:
updated_user = r.json()
person = updated_user.get("person", {})
print(f"Person after update:")
print(f" last_name: {person.get('last_name')}")
print(f" first_name: {person.get('first_name')}")
print(f" phone: {person.get('phone')}")
if person.get("last_name") == "Updated":
print("✅ Person fields updated successfully!")
else:
print("⚠️ Person fields may not have been updated (might not have a person record)")
# Step 6: Test error cases
print("\n" + "=" * 60)
print("STEP 6: Test error cases")
print("=" * 60)
# 6a: Non-existent user
r = await client.patch("/admin/users/99999", json={"is_vip": True}, headers=headers)
print(f"6a - Non-existent user (99999): Status={r.status_code}")
if r.status_code == 404:
print("✅ Correctly returned 404")
else:
print(f"⚠️ Unexpected: {r.text}")
# 6b: Empty update
r = await client.patch(f"/admin/users/{user_id}", json={}, headers=headers)
print(f"6b - Empty update: Status={r.status_code}")
if r.status_code == 400:
print("✅ Correctly returned 400 for empty update")
elif r.status_code == 200:
print("⚠️ Empty update returned 200 (no changes made)")
else:
print(f"⚠️ Unexpected: {r.text}")
# 6c: Invalid field
r = await client.patch(f"/admin/users/{user_id}", json={"nonexistent_field": "test"}, headers=headers)
print(f"6c - Invalid field: Status={r.status_code}")
if r.status_code == 422:
print("✅ Correctly returned 422 for invalid field")
else:
print(f"⚠️ Unexpected: {r.text}")
# 6d: Try to modify superadmin (should be blocked)
r = await client.patch("/admin/users/1", json={"is_vip": True}, headers=headers)
print(f"6d - Modify superadmin: Status={r.status_code}")
if r.status_code == 403:
print("✅ Correctly blocked superadmin modification")
else:
print(f"⚠️ Unexpected: {r.text}")
print("\n" + "=" * 60)
print("ALL TESTS COMPLETED")
print("=" * 60)
return True
if __name__ == "__main__":
success = asyncio.run(test_admin_user_patch())
sys.exit(0 if success else 1)

View File

@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test Script
This script simulates the exact _extract_limit() function and feeds it
the ACTUAL JSON data from the database to verify if the limit extraction
works correctly for corporate tiers.
Author: System Architect
Date: 2026-06-28
"""
import json
import sys
from typing import Any
# =============================================================================
# EXACT copy of _extract_limit() from admin_organizations.py:397
# =============================================================================
def _extract_limit(
rules: dict,
keys: list[str],
default: int = 0,
) -> int:
"""
P0 ARCHITECTURE UNIFICATION: Egyetlen, robusztus függvény a limit értékek
kinyerésére a SubscriptionTier.rules JSONB objektumból.
Két rétegben keres:
1. Közvetlenül a rules objektumban: rules["max_vehicles"]
2. A rules["allowances"] nested objektumban: rules["allowances"]["max_vehicles"]
Ez biztosítja, hogy a JSONB struktúra változásai (pl. 'allowances' nested object)
ne törjék meg a limit számításokat.
Args:
rules: A SubscriptionTier.rules JSONB dict-je.
keys: A keresendő kulcsok listája (pl. ["max_vehicles", "max_assets"]).
Az első találat értéke kerül visszaadásra.
default: Alapértelmezett érték, ha egyik kulcs sem található.
Returns:
int: A talált limit érték, vagy a default.
"""
if not rules or not isinstance(rules, dict):
return default
# 1. szint: Közvetlen keresés a rules objektumban
for key in keys:
value = rules.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
# 2. szint: Keresés a rules["allowances"] nested objektumban
allowances = rules.get("allowances")
if allowances and isinstance(allowances, dict):
for key in keys:
value = allowances.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
# 3. szint: Keresés a rules["limits"] nested objektumban (alternatív név)
limits = rules.get("limits")
if limits and isinstance(limits, dict):
for key in keys:
value = limits.get(key)
if value is not None and isinstance(value, (int, float)):
return int(value)
return default
# =============================================================================
# ACTUAL database JSON data (retrieved via PostgreSQL MCP query on 2026-06-28)
# =============================================================================
# corp_premium_plus_v1 (id=17) — exact JSON as stored in system.subscription_tiers.rules
CORP_PREMIUM_PLUS_V1_RAW = """{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 6000,
"yearly_price": 599.99,
"monthly_price": 59.99
},
"duration": {
"days": 30,
"allow_stacking": true
},
"ad_policy": {
"show_ads": false,
"ad_free_grace_days": 0,
"max_daily_impressions": null
},
"affiliate": {
"referral_bonus_credits": 250,
"commission_rate_percent": 20
},
"lifecycle": {
"is_public": true
},
"marketing": {
"badge": "Plus",
"subtitle": "Nagy flották prémium menedzsmentje.",
"highlight_color": "#8B5CF6"
},
"allowances": {
"max_garages": 10,
"max_vehicles": 50,
"monthly_free_credits": 800
},
"display_name": "Céges Prémium Plus",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC"
],
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 6000,
"yearly_price": 239990,
"monthly_price": 23990
},
"US": {
"currency": "USD",
"credit_price": 6000,
"yearly_price": 699.99,
"monthly_price": 69.99
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 6000,
"yearly_price": 599.99,
"monthly_price": 59.99
}
}
}"""
# corp_premium_v1 (id=16) — exact JSON as stored
CORP_PREMIUM_V1_RAW = """{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 3000,
"yearly_price": 299.99,
"monthly_price": 29.99
},
"duration": {
"days": 30,
"allow_stacking": true
},
"ad_policy": {
"show_ads": false,
"ad_free_grace_days": 0,
"max_daily_impressions": null
},
"affiliate": {
"referral_bonus_credits": 100,
"commission_rate_percent": 15
},
"lifecycle": {
"is_public": true
},
"marketing": {
"badge": "Prémium",
"subtitle": "Közepes flották számára tervezve.",
"highlight_color": "#3B82F6"
},
"allowances": {
"max_garages": 3,
"max_vehicles": 20,
"monthly_free_credits": 300
},
"display_name": "Céges Prémium",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD"
],
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 3000,
"yearly_price": 119990,
"monthly_price": 11990
},
"US": {
"currency": "USD",
"credit_price": 3000,
"yearly_price": 349.99,
"monthly_price": 34.99
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 3000,
"yearly_price": 299.99,
"monthly_price": 29.99
}
}
}"""
# private_pro_v1 (id=14) — exact JSON as stored
PRIVATE_PRO_V1_RAW = """{
"type": "private",
"lifecycle": {
"is_public": true,
"available_until": null
},
"marketing": {
"badge": null,
"subtitle": null
},
"allowances": {
"max_garages": 1,
"max_vehicles": 3,
"monthly_free_credits": 0
},
"pricing_zones": {
"HU": {
"currency": "HUF",
"credit_price": 25000,
"yearly_price": 9990.0,
"monthly_price": 990.0
},
"US": {
"currency": "USD",
"credit_price": 25000,
"yearly_price": 35.9,
"monthly_price": 3.5
},
"DEFAULT": {
"currency": "EUR",
"credit_price": 25000,
"yearly_price": 29.9,
"monthly_price": 2.99
}
}
}"""
# feature_capabilities data (as stored)
CORP_PREMIUM_PLUS_V1_FC_RAW = "{}"
CORP_PREMIUM_V1_FC_RAW = "{}"
PRIVATE_PRO_V1_FC_RAW = """{"export_data": false, "advanced_reports": true, "max_cost_category_depth": 3}"""
# =============================================================================
# JSON double-encoding simulation
# =============================================================================
DOUBLE_ENCODED_RAW = json.dumps(CORP_PREMIUM_PLUS_V1_RAW)
# =============================================================================
# Test harness
# =============================================================================
def print_separator(title: str):
print(f"\n{'='*80}")
print(f" {title}")
print(f"{'='*80}")
def test_single_tier(
tier_name: str,
rules_raw: str,
fc_raw: str,
vehicle_keys: list,
branch_keys: list,
user_keys: list,
expected_vehicles: int,
expected_branches: int,
expected_users: int,
):
"""Test _extract_limit for a single tier, simulating the exact endpoint logic."""
print(f"\n ┌─ Tier: {tier_name}")
rules = json.loads(rules_raw)
fc = json.loads(fc_raw)
print(f" ├─ rules type: {type(rules).__name__}")
print(f" ├─ rules keys: {list(rules.keys())}")
print(f" ├─ 'allowances' key present: {'allowances' in rules}")
if 'allowances' in rules:
print(f" ├─ allowances content: {json.dumps(rules['allowances'], indent=4)}")
base_vehicles = _extract_limit(
rules, vehicle_keys,
_extract_limit(fc, vehicle_keys, 1),
)
base_branches = _extract_limit(
rules, branch_keys,
_extract_limit(fc, branch_keys, 0),
)
base_users = _extract_limit(
rules, user_keys,
_extract_limit(fc, user_keys, 0),
)
print(f" ├─ RESULT: vehicles={base_vehicles} (expected={expected_vehicles})")
print(f" ├─ RESULT: branches={base_branches} (expected={expected_branches})")
print(f" └─ RESULT: users={base_users} (expected={expected_users})")
status = "✅ PASS" if (base_vehicles == expected_vehicles and
base_branches == expected_branches and
base_users == expected_users) else "❌ FAIL"
print(f" {status}")
return status
def test_double_encoding_scenario():
"""Test what happens if the JSON is double-encoded."""
print_separator("SIMULATION: Double-encoded JSON (a known anti-pattern)")
double_encoded = DOUBLE_ENCODED_RAW
print(f"\n Double-encoded value preview: {double_encoded[:80]}...")
print(f" Double-encoded type: {type(double_encoded).__name__}")
parsed = json.loads(double_encoded)
print(f" After json.loads(): type={type(parsed).__name__}")
print(f" After json.loads(): is dict? {isinstance(parsed, dict)}")
print(f" After json.loads(): is str? {isinstance(parsed, str)}")
if isinstance(parsed, str):
print(f"\n ❌ DOUBLE-ENCODING DETECTED!")
print(f" _extract_limit receives a STRING, not a dict!")
print(f" First check: 'if not rules or not isinstance(rules, dict):'")
print(f" → Returns default=0 immediately!")
result = _extract_limit(parsed, ["max_vehicles", "max_assets"], 0)
print(f" _extract_limit(string, ...) = {result}")
else:
print(f"\n ✅ No double-encoding. Data is properly deserialized as dict.")
def test_edge_cases():
"""Test edge cases and potential failure modes."""
print_separator("EDGE CASE TESTS")
result = _extract_limit({}, ["max_vehicles"], 5)
print(f" Empty dict -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit(None, ["max_vehicles"], 5)
print(f" None -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit("not a dict", ["max_vehicles"], 5)
print(f" String input -> {result} (expected: 5) {'' if result == 5 else ''}")
result = _extract_limit({"max_vehicles": 100}, ["max_vehicles", "max_assets"], 0)
print(f" Direct key match -> {result} (expected: 100) {'' if result == 100 else ''}")
result = _extract_limit({"allowances": {"max_vehicles": 50}}, ["max_vehicles", "max_assets"], 0)
print(f" allowances['max_vehicles'] -> {result} (expected: 50) {'' if result == 50 else ''}")
result = _extract_limit({"limits": {"max_vehicles": 25}}, ["max_vehicles", "max_assets"], 0)
print(f" limits['max_vehicles'] -> {result} (expected: 25) {'' if result == 25 else ''}")
# =============================================================================
# MAIN
# =============================================================================
def main():
print_separator("P0 DATA STRUCTURAL AUDIT: _extract_limit() Standalone Test")
print(" Date: 2026-06-28")
print(" Source: system.subscription_tiers (PostgreSQL JSONB)")
# --- Test 1: corp_premium_plus_v1 ---
print_separator("TEST 1: corp_premium_plus_v1 (id=17)")
print(" Expected: vehicles=50, branches=10, users=?")
print(" Note: 'max_users'/'max_members' keys NOT present in rules JSON.")
print(" The function will fall back to feature_capabilities (empty dict {}),")
print(" then to the nested default (0).")
test_single_tier(
tier_name="corp_premium_plus_v1",
rules_raw=CORP_PREMIUM_PLUS_V1_RAW,
fc_raw=CORP_PREMIUM_PLUS_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=50,
expected_branches=10,
expected_users=0,
)
# --- Test 2: corp_premium_v1 ---
print_separator("TEST 2: corp_premium_v1 (id=16)")
test_single_tier(
tier_name="corp_premium_v1",
rules_raw=CORP_PREMIUM_V1_RAW,
fc_raw=CORP_PREMIUM_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=20,
expected_branches=3,
expected_users=0,
)
# --- Test 3: private_pro_v1 ---
print_separator("TEST 3: private_pro_v1 (id=14)")
test_single_tier(
tier_name="private_pro_v1",
rules_raw=PRIVATE_PRO_V1_RAW,
fc_raw=PRIVATE_PRO_V1_FC_RAW,
vehicle_keys=["max_vehicles", "max_assets"],
branch_keys=["max_branches", "max_garages"],
user_keys=["max_users", "max_members"],
expected_vehicles=3,
expected_branches=1,
expected_users=0,
)
# --- Double-encoding simulation ---
test_double_encoding_scenario()
# --- Edge cases ---
test_edge_cases()
# --- Summary ---
print_separator("SUMMARY")
print("""
KEY FINDINGS FROM DB AUDIT:
1. All corporate tiers (corp_premium_plus_v1, corp_premium_v1)
store their limits under rules['allowances'] nested object.
2. The _extract_limit() function correctly searches at 3 levels:
Level 1: Direct keys in root (rules['max_vehicles'])
Level 2: Nested under 'allowances' (rules['allowances']['max_vehicles'])
Level 3: Nested under 'limits' (rules['limits']['max_vehicles'])
3. For corp_premium_plus_v1: allowances.max_vehicles = 50
Function should return 50, not 0.
4. ROOT CAUSE SUSPECTED: If _extract_limit returns 0 at runtime,
the issue is likely:
a) The 'rules' field is double-encoded (string inside JSONB)
b) The wrong code path is being executed (e.g., no active_subs found)
c) The rules dict is empty {} due to data migration issue
""")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,80 @@
# Admin Users List Filter Bug Analysis
**Gitea Issue:** #323
**Scope:** Backend & Frontend (Admin UI)
**Type:** Bug
## Root Cause: Frontend-Backend Parameter Name Mismatch
The admin users list page at [`frontend_admin/pages/users/index.vue`](frontend_admin/pages/users/index.vue:539) sends filter parameters to the backend endpoint [`GET /api/v1/admin/users`](backend/app/api/v1/endpoints/admin.py:491), but the parameter names don't match what the backend expects.
### Frontend sends (frontend_admin/pages/users/index.vue, lines 543-556):
```typescript
// What frontend sends:
params.status_filter = statusFilter.value // "active", "inactive", "deleted", "banned"
params.role_filter = roleFilter.value // "user", "admin", "staff", "superadmin"
params.plan_filter = planFilter.value // "FREE", "PREMIUM", "ENTERPRISE"
```
### Backend expects (backend/app/api/v1/endpoints/admin.py, lines 491-656):
```python
# What backend expects:
role: Optional[str] = Query(None) # NOT role_filter
is_active: Optional[bool] = Query(None) # NOT status_filter
is_deleted: Optional[bool] = Query(None) # NOT status_filter
# plan_filter: NOT SUPPORTED AT ALL
```
## Detailed Issues
### Issue 1: Status Filter (`status_filter` vs `is_active`/`is_deleted`)
| Frontend sends | Should map to backend |
|---------------|----------------------|
| `status_filter=active` | `is_active=true, is_deleted=false` |
| `status_filter=inactive` | `is_active=false, is_deleted=false` |
| `status_filter=deleted` | `is_deleted=true` |
| `status_filter=banned` | `is_active=false` (no `is_banned` field exists) |
### Issue 2: Role Filter (`role_filter` vs `role`)
Simple rename: `role_filter``role`
### Issue 3: Plan Filter (`plan_filter` not supported)
The backend has **no** `plan_filter` or `subscription_plan` query parameter at all. The `User` model does have a `subscription_plan` column (backend/app/models/identity/identity.py:152), so this needs to be added to the backend.
### Issue 4: `person_name` missing from response
The frontend `UserListItem` interface expects `person_name` (line 417), but the backend returns `first_name` and `last_name` separately (lines 640-641). The frontend displays `user.person_name || '—'` (line 246), which means it will always show `—` because `person_name` is never populated.
## Fix Plan
### Backend Changes (backend/app/api/v1/endpoints/admin.py)
1. **Add `plan_filter` query parameter** to the `list_users` endpoint:
- Accept `plan_filter: Optional[str]`
- Filter by `User.subscription_plan == plan_filter`
- Valid values: "FREE", "PREMIUM", "ENTERPRISE" (plus "all")
2. **Add `person_name` to the response** by concatenating `first_name` and `last_name`.
### Frontend Changes (frontend_admin/pages/users/index.vue)
1. **Rename parameter**: Change `params.role_filter``params.role`
2. **Status filter logic**: Replace `params.status_filter` with proper mapping:
- `active``params.is_active = true`
- `inactive``params.is_active = false`
- `deleted``params.is_deleted = true`
- `banned``params.is_active = false` (since banned users are deactivated)
3. **Keep `plan_filter`** as-is (backend will support it after fix)
4. **Fix `person_name`** display: Compute it from `first_name` + `last_name` if available
## Files to Modify
| File | Changes |
|------|---------|
| `backend/app/api/v1/endpoints/admin.py` | Add `plan_filter` param, add `person_name` to response |
| `frontend_admin/pages/users/index.vue` | Fix parameter names and status mapping in `fetchUsers()` |

View File

@@ -0,0 +1,189 @@
# P0 DATA STRUCTURAL AUDIT REPORT: `_extract_limit()` & JSONB Tier Structure
**Date:** 2026-06-28
**Author:** System Architect
**Scope:** [`system.subscription_tiers`](backend/app/models/core_logic.py:68), [`finance.org_subscriptions`](backend/app/models/core_logic.py:143), [`_extract_limit()`](backend/app/api/v1/endpoints/admin_organizations.py:397)
**Status:** ✅ Structural audit complete — `_extract_limit` logic is **correct**, DB JSONB is **valid**
---
## 1. AUDIT: `system.subscription_tiers` (Base Tier Rules)
### Raw JSON Structure
All three corporate tiers have their limits stored at **`rules["allowances"]`** (nested object, 2nd level):
| Tier | ID | `jsonb_typeof(rules)` | `allowances.max_vehicles` | `allowances.max_garages` |
|------|----|----------------------|--------------------------|--------------------------|
| `private_pro_v1` | 14 | `object` ✅ | 3 | 1 |
| `corp_premium_v1` | 16 | `object` ✅ | 20 | 3 |
| `corp_premium_plus_v1` | 17 | `object` ✅ | 50 | 10 |
### Full JSON for `corp_premium_plus_v1` (id=17)
```json
{
"type": "corporate",
"pricing": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 },
"duration": { "days": 30, "allow_stacking": true },
"ad_policy": { "show_ads": false, "ad_free_grace_days": 0, "max_daily_impressions": null },
"affiliate": { "referral_bonus_credits": 250, "commission_rate_percent": 20 },
"lifecycle": { "is_public": true },
"marketing": { "badge": "Plus", "subtitle": "Nagy flották prémium menedzsmentje.", "highlight_color": "#8B5CF6" },
"allowances": { "max_garages": 10, "max_vehicles": 50, "monthly_free_credits": 800 },
"display_name": "Ceiges Preimium Plus",
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD", "SRV_ACCOUNTING_SYNC"],
"pricing_zones": { "HU": { "currency": "HUF", "credit_price": 6000, "yearly_price": 239990, "monthly_price": 23990 }, "DEFAULT": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 } }
}
```
### Confirmed: `rules` is a proper JSONB **object**, NOT a string
```sql
SELECT jsonb_typeof(rules) FROM system.subscription_tiers WHERE id = 17;
-- Result: "object" ✅ (not "string")
```
---
## 2. AUDIT: `finance.org_subscriptions` (Active Subs & Extra Allowances)
| org_id | Sub ID | Tier | Tier ID | `extra_allowances` |
|--------|--------|------|---------|-------------------|
| 15 | 2 | `corp_premium_v1` | 16 | (not queried) |
| 43 | 30 | `private_pro_v1` | 14 | `{}` |
| 44 | 22 | `corp_premium_v1` | 16 | (not queried) |
| 45 | 21 | `corp_premium_plus_v1` | 17 | (not queried) |
| 49 | 20 | `corp_premium_v1` | 16 | (not queried) |
For org_id=43: `extra_allowances` = `{}` (empty JSONB object) — no extra allowances purchased.
---
## 3. TEST SCRIPT: `_extract_limit()` Behavior
### Implementation (exact copy from [`admin_organizations.py:397`](backend/app/api/v1/endpoints/admin_organizations.py:397))
The function searches at **3 levels**:
1. **Level 1**: Direct keys at rules root (`rules["max_vehicles"]`)
2. **Level 2**: Nested under `rules["allowances"]` (`rules["allowances"]["max_vehicles"]`)
3. **Level 3**: Nested under `rules["limits"]` (`rules["limits"]["max_vehicles"]`)
### Test Results — ALL PASS ✅
| Tier | Vehicles | Branches | Users | Status |
|------|----------|----------|-------|--------|
| `corp_premium_plus_v1` | **50** ✅ | **10** ✅ | 0 | **PASS** |
| `corp_premium_v1` | **20** ✅ | **3** ✅ | 0 | **PASS** |
| `private_pro_v1` | **3** ✅ | **1** ✅ | 0 | **PASS** |
### Edge Cases
| Input | Result | Expected | Status |
|-------|--------|----------|--------|
| Empty dict `{}` | 5 (default) | 5 | ✅ |
| `None` | 5 (default) | 5 | ✅ |
| String (not dict) | 5 (default) | 5 | ✅ |
| Direct key match | 100 | 100 | ✅ |
| `allowances.max_vehicles` | 50 | 50 | ✅ |
| `limits.max_vehicles` | 25 | 25 | ✅ |
### Double-Encoding Simulation
When the JSON is **double-encoded** (string stored inside JSONB instead of object):
```
Input: '"{\\"allowances\\": {\\"max_vehicles\\": 50}}"'
→ json.loads() returns str, not dict
→ _extract_limit(str, ...) returns default=0 ❌
```
**Confirmed: Current DB data is NOT double-encoded.** (`jsonb_typeof = "object"`)
---
## 4. ROOT CAUSE ANALYSIS
### The `_extract_limit()` function is **NOT the problem**
All tests confirm the function correctly extracts limits from the current JSONB structure. It fully supports the `allowances` nested format used by all tiers.
### The REAL problem is likely in the **code flow** before `_extract_limit` is called
The endpoint [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:462) has **two code paths**:
#### Path A (line 579-664) — Uses `finance.org_subscriptions` (NEW system)
```python
active_subs = sub_result.scalars().all() # Query finance.org_subscriptions
if active_subs:
base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')]
primary_sub = base_subs_sorted[0] if base_subs_sorted else ...
if primary_sub and primary_sub.tier:
primary_rules = primary_sub.tier.rules or {} # ← THIS must work
```
#### Path B (line 665-691) — Uses `fleet.organizations.subscription_tier_id` (LEGACY)
```python
elif org.subscription_tier:
rules = org.subscription_tier.rules or {} # ← LEGACY path
```
### Potential failure scenarios (outside `_extract_limit`):
| Scenario | Symptom | Likelihood |
|----------|---------|------------|
| **Path A → `active_subs` is empty** (no `finance.org_subscriptions` row) | Falls to Path B | Medium |
| **Path A → `primary_sub.tier` is `None`** (FK broken, tier missing) | Skips limit calc, `subscription_summary` stays `None` | Low |
| **Path B → `org.subscription_tier` points to wrong tier** (old FK mismatch) | Wrong limits returned | Medium |
| **`org.subscription_tier.rules` is `None`** (tier exists but rules column is NULL) | `{} or None``{}` → default | Low |
---
## 5. RECOMMENDATIONS
### ✅ Short-term: Verify runtime dataflow
1. **Test with exact org_id** that shows the 0 limit:
```sql
SELECT os.id, os.org_id, os.tier_id, st.name, st.rules IS NOT NULL as has_rules
FROM finance.org_subscriptions os
JOIN system.subscription_tiers st ON os.tier_id = st.id
WHERE os.org_id = <problematic_org_id> AND os.is_active = true;
```
2. **Check if the endpoint takes Path A or Path B** by inspecting logs/Debug mode.
### ✅ Long-term: Code improvements (separate ticket)
1. **Add logging** at key decision points in [`get_organization_details()`](backend/app/api/v1/endpoints/admin_organizations.py:579-614):
- Log `active_subs` count
- Log which path is taken (A vs B)
- Log `primary_rules` keys and extracted limits
- Log if `primary_sub.tier` is None
2. **Add defensive fallback** — if `_extract_limit` returns 0 but `rules` has `allowances`, log a warning:
```python
base_vehicles = _extract_limit(primary_rules, ["max_vehicles", "max_assets"], fc_fallback)
if base_vehicles == 0 and primary_rules.get("allowances", {}).get("max_vehicles"):
logger.warning(f"_extract_limit returned 0 despite allowances.max_vehicles={primary_rules['allowances']['max_vehicles']}")
```
### 🔍 Most Probable Root Cause
The issue is likely that **the org(s) reporting 0 limits do NOT have an active row in `finance.org_subscriptions`** (Path A), and the **legacy Path B is failing** because `org.subscription_tier.rules` is `None` or the old FK points to a tier that doesn't have `allowances` populated.
**Next step**: Identify the exact org_id(s) that show 0 and check their `finance.org_subscriptions` presence and `fleet.organizations.subscription_tier_id` value.
---
## Appendix: Test Script Location
The standalone test script is at:
[`tests/active/test_extract_limit_standalone.py`](tests/active/test_extract_limit_standalone.py)
Run with:
```bash
docker compose exec sf_api python3 /app/tests/test_extract_limit_standalone.py
```

View File

@@ -0,0 +1,259 @@
# P0 DEEP DIVE DIAGNOSTIC — Subscription Math Trace Report
**Date:** 2026-06-27
**Author:** Fast Coder (Core Developer)
**Scope:** Root cause analysis of `asset_limit` returning 0 for `corp_premium_plus_v1` tier
---
## 1. DATABASE DUMP — Raw JSONB for `corp_premium_plus_v1`
### Query
```sql
SELECT id, name, rules, feature_capabilities
FROM system.subscription_tiers
WHERE name = 'corp_premium_plus_v1';
```
### Result
| Column | Value |
|--------|-------|
| `id` | `17` |
| `name` | `corp_premium_plus_v1` |
| `rules` (JSONB) | See below |
| `feature_capabilities` | `{}` (empty object) |
### Raw `rules` JSONB (pretty-printed)
```json
{
"type": "corporate",
"pricing": {
"currency": "EUR",
"credit_price": 6000,
"yearly_price": 599.99,
"monthly_price": 59.99
},
"duration": {
"days": 30,
"allow_stacking": true
},
"ad_policy": {
"show_ads": false,
"ad_free_grace_days": 0,
"max_daily_impressions": null
},
"affiliate": {
"referral_bonus_credits": 250,
"commission_rate_percent": 20
},
"lifecycle": {
"is_public": true
},
"marketing": {
"badge": "Plus",
"subtitle": "Nagy flották prémium menedzsmentje.",
"highlight_color": "#8B5CF6"
},
"allowances": {
"max_garages": 10,
"max_vehicles": 50,
"monthly_free_credits": 800
},
"display_name": "Céges Prémium Plus",
"entitlements": [
"SRV_DATA_EXPORT",
"SRV_AI_UPLOAD",
"SRV_ACCOUNTING_SYNC"
],
"pricing_zones": {
"HU": { "currency": "HUF", "credit_price": 6000, "yearly_price": 239990, "monthly_price": 23990 },
"US": { "currency": "USD", "credit_price": 6000, "yearly_price": 699.99, "monthly_price": 69.99 },
"DEFAULT": { "currency": "EUR", "credit_price": 6000, "yearly_price": 599.99, "monthly_price": 59.99 }
}
}
```
### Key Observation
The vehicle limit **`max_vehicles: 50`** is stored **inside a nested `allowances` object** within the `rules` JSONB column:
```
rules → allowances → max_vehicles: 50
```
The `feature_capabilities` column is **empty** (`{}`).
---
## 2. MATH TRACE — The Aggregation Logic
### File: [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:600)
### The Formula (line 680)
```python
asset_limit = base_vehicles + extra_vehicles + addon_vehicles
```
Where:
- `base_vehicles` ← extracted from the **base tier's** `rules` + `feature_capabilities` + `extra_allowances`
- `extra_vehicles` ← from `base_extra.get("extra_vehicles", 0)` (admin overrides in `OrganizationSubscription.extra_allowances`)
- `addon_vehicles` ← summed across all active add-on subscriptions
### The `_extract_limit` Function (lines 601630)
```python
def _extract_limit(
rules: dict,
fc: dict,
extra: dict,
vehicle_keys: tuple = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit"),
branch_keys: tuple = ("max_branches", "branch_limit", "max_garages", "garages_limit"),
user_keys: tuple = ("max_users", "user_limit", "max_members", "members_limit"),
default: int = 0,
):
def _pick(keys: tuple) -> int:
# 1. Check root level of rules, fc, extra
for d in (rules, fc, extra):
for k in keys:
val = d.get(k)
if val is not None and isinstance(val, (int, float)):
return int(val)
# 2. Check nested 'allowances' object inside rules
allowances = rules.get("allowances", {})
if isinstance(allowances, dict):
for k in keys:
val = allowances.get(k)
if val is not None and isinstance(val, (int, float)):
return int(val)
return default
return _pick(vehicle_keys), _pick(branch_keys), _pick(user_keys)
```
### How It's Called (line 648)
```python
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
```
Where:
- `rules` = `active_base_sub.tier.rules` (the JSONB shown above)
- `fc` = `active_base_sub.tier.feature_capabilities``{}` (empty)
- `base_extra` = `active_base_sub.extra_allowances``{}` (empty, unless admin set overrides)
---
## 3. ROOT CAUSE ANALYSIS
### Step-by-step trace of `_pick(vehicle_keys)`:
**Phase 1 — Root level scan of `rules`, `fc`, `extra`:**
The function iterates over `(rules, fc, extra)` and checks each key in `vehicle_keys = ("max_vehicles", "vehicle_limit", "max_assets", "assets_limit")`.
| Dictionary | `max_vehicles` | `vehicle_limit` | `max_assets` | `assets_limit` |
|------------|---------------|-----------------|--------------|----------------|
| `rules` (root level) | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
| `fc` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
| `extra` = `{}` | ❌ Not found | ❌ Not found | ❌ Not found | ❌ Not found |
**Result: `None` from all three dictionaries.**
**Phase 2 — Nested `allowances` scan:**
```python
allowances = rules.get("allowances", {}) # → {"max_garages": 10, "max_vehicles": 50, "monthly_free_credits": 800}
```
Now it checks `allowances.get("max_vehicles")`**`50`** ✓
**This SHOULD work!** The nested `allowances` fallback was added as a "P0 JSONB NESTING HOTFIX" (see comment on line 612).
### The Real Problem: TWO CODE PATHS
The `get_organization_details` function has **two separate code paths** for computing the subscription summary:
#### Path A: `active_sub` exists (line 519550) — **OLD CODE**
```python
if active_sub and active_sub.tier:
rules = active_sub.tier.rules or {}
fc = active_sub.tier.feature_capabilities or {}
extra = active_sub.extra_allowances or {}
# Base limits from tier rules (with explicit .get() — no `or` bug)
base_vehicles = rules.get("max_vehicles", fc.get("max_vehicles", 1))
base_branches = rules.get("max_branches", fc.get("max_branches", 0))
base_users = rules.get("max_users", fc.get("max_users", 0))
```
**BUG:** This path uses `rules.get("max_vehicles", ...)` directly — it does **NOT** check the nested `allowances` object! Since `max_vehicles` is at `rules → allowances → max_vehicles`, not at `rules → max_vehicles`, this returns the fallback value of `1`.
#### Path B: Multi-subscription aggregation (line 600699) — **NEW CODE with `_extract_limit`**
```python
if active_base_sub and active_base_sub.tier:
rules = active_base_sub.tier.rules or {}
fc = active_base_sub.tier.feature_capabilities or {}
base_extra = active_base_sub.extra_allowances or {}
base_vehicles, base_branches, base_users = _extract_limit(rules, fc, base_extra)
```
**This path DOES work correctly** because `_extract_limit` checks the nested `allowances` object.
### Which path is being hit?
Looking at the subscription query (lines 443455):
```python
sub_stmt = (
select(OrganizationSubscription)
.options(selectinload(OrganizationSubscription.tier))
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.valid_until >= now,
)
.order_by(OrganizationSubscription.id.desc())
.limit(1)
)
sub_result = await db.execute(sub_stmt)
active_sub = sub_result.scalar_one_or_none()
```
This query returns a **single** subscription row (`.limit(1)`). If the org has a `finance.org_subscriptions` row with `is_active=True` and `valid_until >= now`, then `active_sub` is set and **Path A** is executed — which has the bug.
**Path B** (the multi-subscription aggregation with `_extract_limit`) is only reached if the subscription query returns NO rows (`active_sub` is None), which would be the case if the org has no subscription record.
### Summary of the Bug
| Aspect | Detail |
|--------|--------|
| **Bug Location** | [`admin_organizations.py:525`](backend/app/api/v1/endpoints/admin_organizations.py:525) — Path A |
| **Root Cause** | `rules.get("max_vehicles", ...)` only checks the **root level** of the `rules` JSONB. The actual value is nested inside `rules → allowances → max_vehicles`. |
| **Affected Data** | All tiers that store limits inside a nested `allowances` object (all tiers in the DB use this structure) |
| **Why it returns 0** | It doesn't return 0 — it returns `1` (the fallback from `fc.get("max_vehicles", 1)`). But the **Total Limit** equals the **Extra Limit** because the base calculation returns only the fallback `1` instead of the real `50`. |
| **Fix** | Path A needs to also check the nested `allowances` object, just like `_extract_limit` does in Path B. |
---
## 4. COMPARISON: `evidence.py` Works Correctly
Interestingly, the [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:36) endpoint already handles this correctly:
```python
if tier and tier.rules:
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
max_allowed = max(int(max_vehicles), 1)
```
It directly accesses `rules → allowances → max_vehicles` with the nested `.get("allowances", {}).get("max_vehicles", 1)` pattern.
---
## 5. CONCLUSION
The `_extract_limit` function (Path B) is **correct** — it properly handles the nested `allowances` structure. However, the **old Path A** (lines 519550) is still active and is the one being hit when the org has a valid `finance.org_subscriptions` row. Path A uses `rules.get("max_vehicles", ...)` which only checks the root level of the JSONB, missing the nested `allowances` object.
**The fix is to update Path A** (lines 525527) to also search inside the nested `allowances` object, or to refactor Path A to use the same `_extract_limit` helper that Path B uses.

View File

@@ -14,6 +14,10 @@ type HydrationStrategies = {
type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
export const ChartsUserTrendChart: typeof import("../components/charts/UserTrendChart.vue")['default']
export const GaragesGarageFleetTab: typeof import("../components/garages/GarageFleetTab.vue")['default']
export const GaragesGarageGeneralTab: typeof import("../components/garages/GarageGeneralTab.vue")['default']
export const GaragesGarageSubscriptionTab: typeof import("../components/garages/GarageSubscriptionTab.vue")['default']
export const NuxtWelcome: typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['default'] 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 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 NuxtErrorBoundary: typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
@@ -39,6 +43,10 @@ export const Head: typeof import("../node_modules/nuxt/dist/head/runtime/compone
export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Html'] 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 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 NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
export const LazyChartsUserTrendChart: LazyComponent<typeof import("../components/charts/UserTrendChart.vue")['default']>
export const LazyGaragesGarageFleetTab: LazyComponent<typeof import("../components/garages/GarageFleetTab.vue")['default']>
export const LazyGaragesGarageGeneralTab: LazyComponent<typeof import("../components/garages/GarageGeneralTab.vue")['default']>
export const LazyGaragesGarageSubscriptionTab: LazyComponent<typeof import("../components/garages/GarageSubscriptionTab.vue")['default']>
export const LazyNuxtWelcome: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/welcome.vue")['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 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 LazyNuxtErrorBoundary: LazyComponent<typeof import("../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>

View File

@@ -2311,6 +2311,7 @@ var packages$1 = {
allowances: "Korlátok", allowances: "Korlátok",
max_vehicles: "Max Jármű", max_vehicles: "Max Jármű",
max_garages: "Max Garázs", max_garages: "Max Garázs",
max_users: "Max Dolgozó",
monthly_credits: "Havi Kredit", monthly_credits: "Havi Kredit",
tier_level: "Szint", tier_level: "Szint",
form_name: "Rendszer Név", form_name: "Rendszer Név",
@@ -2389,6 +2390,16 @@ var garages$1 = {
subscription_updated: "előfizetése sikeresen frissítve!", subscription_updated: "előfizetése sikeresen frissítve!",
view_details: "Részletek", view_details: "Részletek",
indefinite: "Határozatlan", indefinite: "Határozatlan",
addons_title: "Kiegészítők (Add-ons)",
addon_vehicles: "Extra Jármű",
addon_branches: "Extra Telephely",
addon_users: "Extra Dolgozó",
active_addons: "Aktív kiegészítők",
no_active_addons: "Nincsenek aktív kiegészítők",
addon_vehicles_expiration: "Extra Jármű lejárati dátuma",
addon_branches_expiration: "Extra Telephely lejárati dátuma",
addon_users_expiration: "Extra Dolgozó lejárati dátuma",
addon_expiration_hint: "Ha üres, a meglévő lejárati dátumok megmaradnak.",
details: { details: {
loading: "Garázs adatainak betöltése...", loading: "Garázs adatainak betöltése...",
retry: "Újra", retry: "Újra",
@@ -2462,6 +2473,8 @@ var garages$1 = {
vehicles_used: "Járművek", vehicles_used: "Járművek",
branches_used: "Telephelyek", branches_used: "Telephelyek",
employees_used: "Dolgozók", employees_used: "Dolgozók",
branches: "telephely",
employees: "dolgozó",
status: "Státusz", status: "Státusz",
subscription_history: "Előfizetési Előzmények", subscription_history: "Előfizetési Előzmények",
history_date: "Dátum", history_date: "Dátum",
@@ -2545,10 +2558,109 @@ var garages$1 = {
no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez." no_fleet_access: "Nincs jogosultságod a flotta adatok megtekintéséhez."
} }
}; };
var users$1 = {
title: "Felhasználó Kezelés",
subtitle: "Felhasználók listája, keresés, szűrés és tömeges műveletek",
loading: "Felhasználók betöltése...",
retry: "Újra",
search_placeholder: "Keresés email, név vagy telefonszám alapján...",
all_statuses: "Összes státusz",
status_active: "Aktív",
status_inactive: "Inaktív",
status_deleted: "Törölt",
status_banned: "Kitiltott",
all_roles: "Összes szerepkör",
role_user: "Felhasználó",
role_admin: "Admin",
role_staff: "Munkatárs",
role_superadmin: "Superadmin",
all_plans: "Összes csomag",
plan_free: "Ingyenes",
plan_premium: "Prémium",
plan_enterprise: "Vállalati",
total_users: "Összes Felhasználó",
active_users: "Aktív Felhasználók",
deleted_users: "Törölt Felhasználók",
banned_users: "Kitiltott Felhasználók",
new_today: "Ma Regisztrált",
filtered_results: "Szűrt találat",
selected_count: "{count} felhasználó kiválasztva",
bulk_activate: "Aktiválás",
bulk_deactivate: "Deaktiválás",
clear_selection: "Kijelölés törlése",
email: "E-mail",
name: "Név",
role: "Szerepkör",
status: "Státusz",
"package": "Csomag",
registration: "Regisztráció",
language: "Nyelv",
actions: "Műveletek",
view: "Megtekintés",
activate: "Aktiválás",
deactivate: "Deaktiválás",
no_results: "Nincs a keresésnek megfelelő felhasználó.",
showing: "Mutató",
prev: "Előző",
next: "Következő",
details: {
loading: "Felhasználó adatainak betöltése...",
retry: "Újra",
back: "Vissza a felhasználókhoz",
edit: "Felhasználó szerkesztése",
edit_user: "Felhasználó szerkesztése",
profile_tab: "Profil",
memberships_tab: "Tagságok",
account_info: "Fiók Információk",
user_id: "Felhasználó ID",
registration_date: "Regisztráció Dátuma",
last_login: "Utolsó Belépés",
ui_mode: "UI Mód",
region: "Régió",
currency: "Pénznem",
subscription_info: "Előfizetés Információk",
subscription_expires: "Lejárat Dátuma",
scope_level: "Hatáskör Szint",
scope_id: "Hatáskör ID",
max_vehicles: "Max Járművek",
max_garages: "Max Garázsok",
personal_info: "Személyes Információk",
last_name: "Vezetéknév",
first_name: "Keresztnév",
phone: "Telefonszám",
birth_place: "Születési Hely",
birth_date: "Születési Dátum",
mothers_name: "Anyja Vezetékneve",
mothers_first_name: "Anyja Keresztneve",
address: "Cím",
zip: "Irányítószám",
city: "Város",
street: "Utca",
house_number: "Házszám",
no_person_record: "Nem található személyes adat ehhez a felhasználóhoz.",
memberships: "Szervezeti Tagságok",
memberships_count: "{count} tagság",
no_memberships: "Ez a felhasználó nem tagja egyetlen szervezetnek sem.",
org_name: "Szervezet",
org_role: "Szerepkör",
member_status: "Státusz",
joined_at: "Csatlakozott",
verified: "Ellenőrzött",
verified_yes: "Igen",
verified_no: "Nem",
preferred_language: "Előnyben Részesített Nyelv",
is_active: "Aktív",
is_vip: "VIP",
cancel: "Mégse",
save: "Változtatások Mentése",
saving: "Mentés..."
}
};
const locale_hu_46json_ee06c965 = { const locale_hu_46json_ee06c965 = {
permissions: permissions$1, permissions: permissions$1,
packages: packages$1, packages: packages$1,
garages: garages$1 garages: garages$1,
users: users$1
}; };
var permissions = { var permissions = {
@@ -2610,6 +2722,7 @@ var packages = {
allowances: "Allowances", allowances: "Allowances",
max_vehicles: "Max Vehicles", max_vehicles: "Max Vehicles",
max_garages: "Max Garages", max_garages: "Max Garages",
max_users: "Max Users",
monthly_credits: "Monthly Credits", monthly_credits: "Monthly Credits",
tier_level: "Level", tier_level: "Level",
form_name: "System Name", form_name: "System Name",
@@ -2688,6 +2801,16 @@ var garages = {
subscription_updated: "subscription updated successfully!", subscription_updated: "subscription updated successfully!",
view_details: "Details", view_details: "Details",
indefinite: "Indefinite", indefinite: "Indefinite",
addons_title: "Add-ons",
addon_vehicles: "Extra Vehicle",
addon_branches: "Extra Branch",
addon_users: "Extra Employee",
active_addons: "Active Add-ons",
no_active_addons: "No active add-ons",
addon_vehicles_expiration: "Extra Vehicle Expiration",
addon_branches_expiration: "Extra Branch Expiration",
addon_users_expiration: "Extra Employee Expiration",
addon_expiration_hint: "If empty, existing expiration dates remain.",
details: { details: {
loading: "Loading garage details...", loading: "Loading garage details...",
retry: "Retry", retry: "Retry",
@@ -2761,6 +2884,8 @@ var garages = {
vehicles_used: "Vehicles", vehicles_used: "Vehicles",
branches_used: "Branches", branches_used: "Branches",
employees_used: "Employees", employees_used: "Employees",
branches: "branches",
employees: "employees",
status: "Status", status: "Status",
subscription_history: "Subscription History", subscription_history: "Subscription History",
history_date: "Date", history_date: "Date",
@@ -2844,10 +2969,109 @@ var garages = {
no_fleet_access: "You don't have permission to view fleet data." no_fleet_access: "You don't have permission to view fleet data."
} }
}; };
var users = {
title: "User Management",
subtitle: "User list, search, filters and bulk actions",
loading: "Loading users...",
retry: "Retry",
search_placeholder: "Search by email, name or phone...",
all_statuses: "All statuses",
status_active: "Active",
status_inactive: "Inactive",
status_deleted: "Deleted",
status_banned: "Banned",
all_roles: "All roles",
role_user: "User",
role_admin: "Admin",
role_staff: "Staff",
role_superadmin: "Superadmin",
all_plans: "All plans",
plan_free: "Free",
plan_premium: "Premium",
plan_enterprise: "Enterprise",
total_users: "Total Users",
active_users: "Active Users",
deleted_users: "Deleted Users",
banned_users: "Banned Users",
new_today: "New Today",
filtered_results: "Filtered results",
selected_count: "{count} user(s) selected",
bulk_activate: "Activate",
bulk_deactivate: "Deactivate",
clear_selection: "Clear",
email: "Email",
name: "Name",
role: "Role",
status: "Status",
"package": "Package",
registration: "Registration",
language: "Language",
actions: "Actions",
view: "View",
activate: "Activate",
deactivate: "Deactivate",
no_results: "No users match your search.",
showing: "Showing",
prev: "Previous",
next: "Next",
details: {
loading: "Loading user details...",
retry: "Retry",
back: "Back to Users",
edit: "Edit User",
edit_user: "Edit User",
profile_tab: "Profile",
memberships_tab: "Memberships",
account_info: "Account Information",
user_id: "User ID",
registration_date: "Registration Date",
last_login: "Last Login",
ui_mode: "UI Mode",
region: "Region",
currency: "Currency",
subscription_info: "Subscription Information",
subscription_expires: "Expires At",
scope_level: "Scope Level",
scope_id: "Scope ID",
max_vehicles: "Max Vehicles",
max_garages: "Max Garages",
personal_info: "Personal Information",
last_name: "Last Name",
first_name: "First Name",
phone: "Phone",
birth_place: "Birth Place",
birth_date: "Birth Date",
mothers_name: "Mother's Name",
mothers_first_name: "Mother's First Name",
address: "Address",
zip: "ZIP Code",
city: "City",
street: "Street",
house_number: "House Number",
no_person_record: "No personal information record found for this user.",
memberships: "Organization Memberships",
memberships_count: "{count} membership(s)",
no_memberships: "This user is not a member of any organization.",
org_name: "Organization",
org_role: "Role",
member_status: "Status",
joined_at: "Joined",
verified: "Verified",
verified_yes: "Yes",
verified_no: "No",
preferred_language: "Preferred Language",
is_active: "Active",
is_vip: "VIP",
cancel: "Cancel",
save: "Save Changes",
saving: "Saving..."
}
};
const locale_en_46json_0b63539c = { const locale_en_46json_0b63539c = {
permissions: permissions, permissions: permissions,
packages: packages, packages: packages,
garages: garages garages: garages,
users: users
}; };
// @ts-nocheck // @ts-nocheck
@@ -3496,16 +3720,16 @@ _wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
const assets = { const assets = {
"/index.mjs": { "/index.mjs": {
"type": "text/javascript; charset=utf-8", "type": "text/javascript; charset=utf-8",
"etag": "\"253ec-9gWita9FIFOcxrWJJImCHqiAiEc\"", "etag": "\"26f47-FxUsDl+Qw3SM1eutSdt3YslLdnY\"",
"mtime": "2026-06-26T13:42:06.440Z", "mtime": "2026-06-29T13:04:36.052Z",
"size": 152556, "size": 159559,
"path": "index.mjs" "path": "index.mjs"
}, },
"/index.mjs.map": { "/index.mjs.map": {
"type": "application/json", "type": "application/json",
"etag": "\"83251-gdWWzINLTOdOWQypEY8kD82/CME\"", "etag": "\"83331-ZjNHkLjUBSxWFNgfXByOQYuJexY\"",
"mtime": "2026-06-26T13:42:06.440Z", "mtime": "2026-06-29T13:04:36.052Z",
"size": 537169, "size": 537393,
"path": "index.mjs.map" "path": "index.mjs.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

@@ -1 +1 @@
import{K as a,B as i,C as o,h as l,L as c}from"./CGTxeHv7.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h as default}; import{N as a,h as i,G as o,s as l,O as c}from"./Dc9IRd06.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h 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{S as _,l as u,m as a}from"./Dc9IRd06.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function m(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function y(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:m,getRegion:y}});export{h as u};

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

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

@@ -1 +1 @@
import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./CGTxeHv7.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 a({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)=>(i(),u("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}; import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./Dc9IRd06.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 a({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)=>(i(),u("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};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as h}from"./BPZZgRgP.js";import{p as v,e as y,g as _,h as k,c as r,a as t,f as S,i as s,t as c,j as u,k as V,l as p,v as m,m as x,b as L,w as C,q as f,o as i,d as N}from"./CGTxeHv7.js";const B=v("/sf_logo.png"),E={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},R={class:"w-full max-w-md"},j={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},A={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={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"},F={class:"mt-6 text-center"},P=y({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=h;return i(),r("div",E,[t("div",R,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+B+'" 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(),r("div",A,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(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]=n=>x(a)?a.value=n: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]=n=>x(l)?l.value=n: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(l)]])]),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(),r("svg",D,[...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",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{P as default}; import{_ as y}from"./C94yYWrE.js";import{p as v,e as h,q as _,s as k,c as r,a as t,v as S,i as s,t as c,j as u,x as V,y as p,z as m,A as x,b as L,w as C,l as f,o as i,d as N}from"./Dc9IRd06.js";const A=v("/sf_logo.png"),B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},R={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},j={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={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"},F={class:"mt-6 text-center"},z=h({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=y;return i(),r("div",B,[t("div",E,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+A+'" 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",R,[s(o).error?(i(),r("div",j,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(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]=n=>x(a)?a.value=n: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]=n=>x(l)?l.value=n: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(l)]])]),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(),r("svg",D,[...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",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{z as default};

View File

@@ -0,0 +1 @@
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,P as r}from"./Dc9IRd06.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as a}from"./BPZZgRgP.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./CGTxeHv7.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 u({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 c(),l("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,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default}; import{_ as a}from"./C94yYWrE.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./Dc9IRd06.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 u({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 c(),l("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,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};

View File

@@ -1 +0,0 @@
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,M as r}from"./CGTxeHv7.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};

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

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

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

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{a7 as _,q as u,z as a}from"./CGTxeHv7.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function y(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function m(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:y,getRegion:m}});export{h as u};

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import { executeAsync } from "/app/node_modules/unctx/dist/index.mjs"; import { executeAsync } from "/app/node_modules/unctx/dist/index.mjs";
import { e as defineNuxtRouteMiddleware, c as useCookie, n as navigateTo, b as useAuthStore } from "../server.mjs"; import { e as defineNuxtRouteMiddleware, d as useCookie, n as navigateTo, c as useAuthStore } from "../server.mjs";
import "vue"; import "vue";
import "/app/node_modules/ofetch/dist/node.mjs"; import "/app/node_modules/ofetch/dist/node.mjs";
import "#internal/nuxt/paths"; import "#internal/nuxt/paths";
@@ -50,4 +50,4 @@ const auth = defineNuxtRouteMiddleware(async (to, from) => {
export { export {
auth as default auth as default
}; };
//# sourceMappingURL=auth-B3jrOAhu.js.map //# sourceMappingURL=auth-Cq1Vc_k9.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"auth-B3jrOAhu.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (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 // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"names":["__executeAsync"],"mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;"} {"version":3,"file":"auth-Cq1Vc_k9.js","sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (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 // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"names":["__executeAsync"],"mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;"}

View File

@@ -1 +1 @@
{"file":"auth-B3jrOAhu.js","mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;","names":["__executeAsync"],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (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 // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"version":3} {"file":"auth-Cq1Vc_k9.js","mappings":";;;;;;;;;;;;;;;;;;AAEE,MAAA,iCAA0B,OAAA,IAAA,SAAA;AAAA,MAAA,QAAA;AACxB,MAAA,GAAA,SAAA,UAAA;AACF;AAAA,EAGA;AACA,sBAAiB,UAAO,cAAA;AACtB,MAAA,CAAA,mBAAkB;AACpB,WAAA,WAAA,QAAA;AAAA,EAGA;AAGA,oBAAe,aAAM;AACnB,MAAA,CAAA,UAAI,MAAA;AACF,QAAA;AACF;AAAA,MAAA,CAAA,QAAQ,SAAA,IAAAA,aAAA,MAAA,UAAA,UAAA,CAAA,GAAA,MAAA,QAAA,UAAA;AAAA;AAAA,IAEN,QAAA;AACA;AACF,aAAA,WAAA,QAAA;AAAA,IACF;AAAA,EAGA;AACE,MAAA,CAAA,UAAU,MAAO;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EAGA;AACA,QAAM,eAAY,CAAA,cAAe,SAAY,aAAY,aAAA,aAAA;AAEzD,QAAK,YAAa,UAAS,KAAA,QAAW,IAAA,YAAA;AAEpC,MAAA,CAAA,aAAU,SAAO,QAAA,GAAA;AACjB;AACF,WAAA,WAAA,QAAA;AAAA,EACF;;","names":["__executeAsync"],"sources":["../../../../middleware/auth.ts"],"sourcesContent":["export default defineNuxtRouteMiddleware(async (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 // ── RBAC: Verify user role from Pinia authStore ──────────────────────\n const authStore = useAuthStore()\n\n // If the user profile hasn't been loaded yet, try to fetch it\n if (!authStore.user) {\n try {\n await authStore.fetchUser()\n } catch {\n // Token is invalid or expired — clear and redirect to login\n authStore.logout()\n return navigateTo('/login')\n }\n }\n\n // After fetch, check if user is still null (fetch failed silently)\n if (!authStore.user) {\n authStore.logout()\n return navigateTo('/login')\n }\n\n // Allowed staff roles that can access the admin panel\n const allowedRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']\n const userRole = (authStore.user.role || '').toUpperCase()\n\n if (!allowedRoles.includes(userRole)) {\n // Standard USER or unknown role — kick them out, clear token, redirect\n authStore.logout()\n return navigateTo('/login')\n }\n})\n"],"version":3}

View File

@@ -1,10 +1,10 @@
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js"; import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
import { defineComponent, computed, ref, mergeProps, withCtx, createVNode, toDisplayString, unref, openBlock, createBlock, createTextVNode, useSSRContext } from "vue"; import { defineComponent, computed, ref, mergeProps, withCtx, createVNode, toDisplayString, unref, openBlock, createBlock, createTextVNode, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderAttr, ssrRenderList, ssrRenderStyle, ssrInterpolate, ssrRenderSlot } from "vue/server-renderer"; import { ssrRenderAttrs, ssrRenderClass, ssrRenderComponent, ssrRenderAttr, ssrRenderList, ssrRenderStyle, ssrInterpolate, ssrRenderSlot } from "vue/server-renderer";
import { publicAssetsURL } from "#internal/nuxt/paths"; import { publicAssetsURL } from "#internal/nuxt/paths";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { u as useRegionStore } from "./region-0mvXqaMM.js"; import { u as useRegionStore } from "./region-0mvXqaMM.js";
import { a as useRouter, b as useAuthStore, d as useI18n } from "../server.mjs"; import { b as useRouter, c as useAuthStore, a as useI18n } from "../server.mjs";
import "/app/node_modules/klona/dist/index.mjs"; import "/app/node_modules/klona/dist/index.mjs";
import "/app/node_modules/hookable/dist/index.mjs"; import "/app/node_modules/hookable/dist/index.mjs";
import "/app/node_modules/ufo/dist/index.mjs"; import "/app/node_modules/ufo/dist/index.mjs";
@@ -71,10 +71,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
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>', 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: [ children: [
{ {
path: "/", path: "/users",
/* P0 HOTFIX: placeholder until /users page is built */
label: "Felhasználók listája", 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>' 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>'
},
{
path: "/persons",
label: "Személyek",
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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>'
} }
] ]
} }
@@ -117,6 +121,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
if (route.path === "/") return "Dashboard"; if (route.path === "/") return "Dashboard";
if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés"; if (route.path.startsWith("/permissions")) return "Jogosultság Kezelés";
if (route.path.startsWith("/garages")) return "Garázsok"; if (route.path.startsWith("/garages")) return "Garázsok";
if (route.path.startsWith("/persons")) return "Személyek";
if (route.path.startsWith("/users")) return "Felhasználók"; if (route.path.startsWith("/users")) return "Felhasználók";
if (route.path.startsWith("/logs")) return "Rendszernaplók"; if (route.path.startsWith("/logs")) return "Rendszernaplók";
if (route.path.startsWith("/packages")) return "Csomagok"; if (route.path.startsWith("/packages")) return "Csomagok";
@@ -287,4 +292,4 @@ _sfc_main.setup = (props, ctx) => {
export { export {
_sfc_main as default _sfc_main as default
}; };
//# sourceMappingURL=default-C9mWt9Pc.js.map //# sourceMappingURL=default-C8swInD9.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

@@ -58,6 +58,7 @@ const resource = {
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Allowances" } }, "allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Allowances" } },
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } }, "max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } }, "max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
"max_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Users" } },
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Credits" } }, "monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Monthly Credits" } },
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Level" } }, "tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Level" } },
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Name" } }, "form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "System Name" } },
@@ -136,6 +137,16 @@ const resource = {
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "subscription updated successfully!" } }, "subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "subscription updated successfully!" } },
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Details" } }, "view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Details" } },
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Indefinite" } }, "indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Indefinite" } },
"addons_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Add-ons" } },
"addon_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Vehicle" } },
"addon_branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Branch" } },
"addon_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Employee" } },
"active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active Add-ons" } },
"no_active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No active add-ons" } },
"addon_vehicles_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Vehicle Expiration" } },
"addon_branches_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Branch Expiration" } },
"addon_users_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Employee Expiration" } },
"addon_expiration_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "If empty, existing expiration dates remain." } },
"details": { "details": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading garage details..." } }, "loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading garage details..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } }, "retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
@@ -210,6 +221,8 @@ const resource = {
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vehicles" } }, "vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vehicles" } },
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Branches" } }, "branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Branches" } },
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Employees" } }, "employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Employees" } },
"branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "branches" } },
"employees": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "employees" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } }, "status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription History" } }, "subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription History" } },
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Date" } }, "history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Date" } },
@@ -292,9 +305,107 @@ const resource = {
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No vehicles assigned to this garage yet." } }, "no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No vehicles assigned to this garage yet." } },
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "You don't have permission to view fleet data." } } "no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "You don't have permission to view fleet data." } }
} }
},
"users": {
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User Management" } },
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User list, search, filters and bulk actions" } },
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading users..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Search by email, name or phone..." } },
"all_statuses": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All statuses" } },
"status_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active" } },
"status_inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inactive" } },
"status_deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deleted" } },
"status_banned": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Banned" } },
"all_roles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All roles" } },
"role_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User" } },
"role_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Admin" } },
"role_staff": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Staff" } },
"role_superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
"all_plans": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "All plans" } },
"plan_free": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Free" } },
"plan_premium": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Premium" } },
"plan_enterprise": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Enterprise" } },
"total_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Total Users" } },
"active_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active Users" } },
"deleted_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deleted Users" } },
"banned_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Banned Users" } },
"new_today": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "New Today" } },
"filtered_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Filtered results" } },
"selected_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " user(s) selected" }] } },
"bulk_activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Activate" } },
"bulk_deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deactivate" } },
"clear_selection": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Clear" } },
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Email" } },
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Name" } },
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
"package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Package" } },
"registration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Registration" } },
"language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Language" } },
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Actions" } },
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "View" } },
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Activate" } },
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deactivate" } },
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No users match your search." } },
"showing": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Showing" } },
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Previous" } },
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Next" } },
"details": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Loading user details..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Retry" } },
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Back to Users" } },
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit User" } },
"edit_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Edit User" } },
"profile_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Profile" } },
"memberships_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Memberships" } },
"account_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Account Information" } },
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "User ID" } },
"registration_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Registration Date" } },
"last_login": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Last Login" } },
"ui_mode": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "UI Mode" } },
"region": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Region" } },
"currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Currency" } },
"subscription_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Subscription Information" } },
"subscription_expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Expires At" } },
"scope_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Scope Level" } },
"scope_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Scope ID" } },
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Vehicles" } },
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garages" } },
"personal_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Personal Information" } },
"last_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Last Name" } },
"first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "First Name" } },
"phone": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Phone" } },
"birth_place": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Birth Place" } },
"birth_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Birth Date" } },
"mothers_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mother's Name" } },
"mothers_first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mother's First Name" } },
"address": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Address" } },
"zip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "ZIP Code" } },
"city": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "City" } },
"street": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Street" } },
"house_number": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "House Number" } },
"no_person_record": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No personal information record found for this user." } },
"memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Organization Memberships" } },
"memberships_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " membership(s)" }] } },
"no_memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "This user is not a member of any organization." } },
"org_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Organization" } },
"org_role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Role" } },
"member_status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Status" } },
"joined_at": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Joined" } },
"verified": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Verified" } },
"verified_yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Yes" } },
"verified_no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "No" } },
"preferred_language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Preferred Language" } },
"is_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Active" } },
"is_vip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "VIP" } },
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cancel" } },
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Save Changes" } },
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Saving..." } }
}
} }
}; };
export { export {
resource as default resource as default
}; };
//# sourceMappingURL=en-BI6r-iRW.js.map //# sourceMappingURL=en-BPBMPZpX.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"en-BI6r-iRW.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"} {"version":3,"file":"en-BPBMPZpX.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}

View File

@@ -1 +1 @@
{"file":"en-BI6r-iRW.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3} {"file":"en-BPBMPZpX.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1 +0,0 @@
{"file":"entry-styles-1.mjs-Be0Mk_0H.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-1.mjs-C-JbU1OR.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1 +0,0 @@
{"file":"entry-styles-2.mjs-CiZbT56B.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-e4rjr5TT.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

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

View File

@@ -0,0 +1,8 @@
import style_0 from "./entry-styles-1.mjs-C-JbU1OR.js";
import style_1 from "./entry-styles-2.mjs-e4rjr5TT.js";
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0,
style_1,
style_2
]

View File

@@ -1,8 +0,0 @@
import style_0 from "./entry-styles-1.mjs-Be0Mk_0H.js";
import style_1 from "./entry-styles-2.mjs-CiZbT56B.js";
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0,
style_1,
style_2
]

View File

@@ -1,4 +1,4 @@
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js"; import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue"; import { mergeProps, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer"; import { ssrRenderAttrs, ssrInterpolate, ssrRenderComponent } from "vue/server-renderer";
import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.js"; import { _ as _export_sfc } from "./_plugin-vue_export-helper-1tPrXgE0.js";
@@ -94,4 +94,4 @@ const error404 = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-1
export { export {
error404 as default error404 as default
}; };
//# sourceMappingURL=error-404-DPt4byml.js.map //# sourceMappingURL=error-404-qU7XehyZ.js.map

View File

@@ -58,6 +58,7 @@ const resource = {
"allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Korlátok" } }, "allowances": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Korlátok" } },
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Jármű" } }, "max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Jármű" } },
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázs" } }, "max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázs" } },
"max_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Dolgozó" } },
"monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Kredit" } }, "monthly_credits": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Havi Kredit" } },
"tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szint" } }, "tier_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szint" } },
"form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszer Név" } }, "form_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Rendszer Név" } },
@@ -136,6 +137,16 @@ const resource = {
"subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "előfizetése sikeresen frissítve!" } }, "subscription_updated": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "előfizetése sikeresen frissítve!" } },
"view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Részletek" } }, "view_details": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Részletek" } },
"indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Határozatlan" } }, "indefinite": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Határozatlan" } },
"addons_title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kiegészítők (Add-ons)" } },
"addon_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Jármű" } },
"addon_branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Telephely" } },
"addon_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Dolgozó" } },
"active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív kiegészítők" } },
"no_active_addons": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincsenek aktív kiegészítők" } },
"addon_vehicles_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Jármű lejárati dátuma" } },
"addon_branches_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Telephely lejárati dátuma" } },
"addon_users_expiration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Extra Dolgozó lejárati dátuma" } },
"addon_expiration_hint": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ha üres, a meglévő lejárati dátumok megmaradnak." } },
"details": { "details": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázs adatainak betöltése..." } }, "loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Garázs adatainak betöltése..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } }, "retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
@@ -210,6 +221,8 @@ const resource = {
"vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Járművek" } }, "vehicles_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Járművek" } },
"branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telephelyek" } }, "branches_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telephelyek" } },
"employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dolgozók" } }, "employees_used": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dolgozók" } },
"branches": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "telephely" } },
"employees": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "dolgozó" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } }, "status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
"subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetési Előzmények" } }, "subscription_history": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetési Előzmények" } },
"history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dátum" } }, "history_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Dátum" } },
@@ -292,9 +305,107 @@ const resource = {
"no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ehhez a garázshoz még nincsenek járművek rendelve." } }, "no_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ehhez a garázshoz még nincsenek járművek rendelve." } },
"no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs jogosultságod a flotta adatok megtekintéséhez." } } "no_fleet_access": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs jogosultságod a flotta adatok megtekintéséhez." } }
} }
},
"users": {
"title": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó Kezelés" } },
"subtitle": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználók listája, keresés, szűrés és tömeges műveletek" } },
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználók betöltése..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
"search_placeholder": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keresés email, név vagy telefonszám alapján..." } },
"all_statuses": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes státusz" } },
"status_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív" } },
"status_inactive": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Inaktív" } },
"status_deleted": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törölt" } },
"status_banned": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kitiltott" } },
"all_roles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes szerepkör" } },
"role_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó" } },
"role_admin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Admin" } },
"role_staff": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Munkatárs" } },
"role_superadmin": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Superadmin" } },
"all_plans": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes csomag" } },
"plan_free": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ingyenes" } },
"plan_premium": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Prémium" } },
"plan_enterprise": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vállalati" } },
"total_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Összes Felhasználó" } },
"active_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív Felhasználók" } },
"deleted_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Törölt Felhasználók" } },
"banned_users": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kitiltott Felhasználók" } },
"new_today": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ma Regisztrált" } },
"filtered_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szűrt találat" } },
"selected_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " felhasználó kiválasztva" }] } },
"bulk_activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiválás" } },
"bulk_deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiválás" } },
"clear_selection": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Kijelölés törlése" } },
"email": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "E-mail" } },
"name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Név" } },
"role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerepkör" } },
"status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
"package": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csomag" } },
"registration": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Regisztráció" } },
"language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nyelv" } },
"actions": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Műveletek" } },
"view": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Megtekintés" } },
"activate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktiválás" } },
"deactivate": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Deaktiválás" } },
"no_results": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nincs a keresésnek megfelelő felhasználó." } },
"showing": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mutató" } },
"prev": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előző" } },
"next": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Következő" } },
"details": {
"loading": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó adatainak betöltése..." } },
"retry": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Újra" } },
"back": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vissza a felhasználókhoz" } },
"edit": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó szerkesztése" } },
"edit_user": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó szerkesztése" } },
"profile_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Profil" } },
"memberships_tab": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Tagságok" } },
"account_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Fiók Információk" } },
"user_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Felhasználó ID" } },
"registration_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Regisztráció Dátuma" } },
"last_login": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Utolsó Belépés" } },
"ui_mode": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "UI Mód" } },
"region": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Régió" } },
"currency": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Pénznem" } },
"subscription_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előfizetés Információk" } },
"subscription_expires": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Lejárat Dátuma" } },
"scope_level": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hatáskör Szint" } },
"scope_id": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Hatáskör ID" } },
"max_vehicles": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Járművek" } },
"max_garages": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Max Garázsok" } },
"personal_info": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Személyes Információk" } },
"last_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Vezetéknév" } },
"first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Keresztnév" } },
"phone": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Telefonszám" } },
"birth_place": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Születési Hely" } },
"birth_date": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Születési Dátum" } },
"mothers_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Anyja Vezetékneve" } },
"mothers_first_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Anyja Keresztneve" } },
"address": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Cím" } },
"zip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Irányítószám" } },
"city": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Város" } },
"street": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Utca" } },
"house_number": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Házszám" } },
"no_person_record": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nem található személyes adat ehhez a felhasználóhoz." } },
"memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szervezeti Tagságok" } },
"memberships_count": { "t": 0, "b": { "t": 2, "i": [{ "t": 4, "k": "count" }, { "t": 3, "v": " tagság" }] } },
"no_memberships": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ez a felhasználó nem tagja egyetlen szervezetnek sem." } },
"org_name": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szervezet" } },
"org_role": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Szerepkör" } },
"member_status": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Státusz" } },
"joined_at": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Csatlakozott" } },
"verified": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Ellenőrzött" } },
"verified_yes": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Igen" } },
"verified_no": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Nem" } },
"preferred_language": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Előnyben Részesített Nyelv" } },
"is_active": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Aktív" } },
"is_vip": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "VIP" } },
"cancel": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mégse" } },
"save": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Változtatások Mentése" } },
"saving": { "t": 0, "b": { "t": 2, "i": [{ "t": 3 }], "s": "Mentés..." } }
}
} }
}; };
export { export {
resource as default resource as default
}; };
//# sourceMappingURL=hu-Ck1VzT5S.js.map //# sourceMappingURL=hu-DFYycw88.js.map

View File

@@ -1 +1 @@
{"version":3,"file":"hu-Ck1VzT5S.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"} {"version":3,"file":"hu-DFYycw88.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}

View File

@@ -1 +1 @@
{"file":"hu-Ck1VzT5S.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3} {"file":"hu-DFYycw88.js","mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","names":[],"sources":[],"sourcesContent":[],"version":3}

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

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

@@ -2,7 +2,7 @@ import { defineComponent, ref, computed, unref, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderClass, ssrRenderList, ssrIncludeBooleanAttr, ssrRenderAttr } from "vue/server-renderer"; import { ssrRenderAttrs, ssrInterpolate, ssrRenderClass, ssrRenderList, ssrIncludeBooleanAttr, ssrRenderAttr } from "vue/server-renderer";
import "/app/node_modules/hookable/dist/index.mjs"; import "/app/node_modules/hookable/dist/index.mjs";
import "/app/node_modules/klona/dist/index.mjs"; import "/app/node_modules/klona/dist/index.mjs";
import { d as useI18n } from "../server.mjs"; import { a as useI18n } from "../server.mjs";
import "/app/node_modules/ofetch/dist/node.mjs"; import "/app/node_modules/ofetch/dist/node.mjs";
import "#internal/nuxt/paths"; import "#internal/nuxt/paths";
import "/app/node_modules/unctx/dist/index.mjs"; import "/app/node_modules/unctx/dist/index.mjs";
@@ -209,4 +209,4 @@ _sfc_main.setup = (props, ctx) => {
export { export {
_sfc_main as default _sfc_main as default
}; };
//# sourceMappingURL=index-DgwDY6Ie.js.map //# sourceMappingURL=index-BD2eKDSC.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

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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
import { _ as __nuxt_component_0 } from "./nuxt-link-C5PnX__I.js"; import { _ as __nuxt_component_0 } from "./nuxt-link-Ci8vU-Yt.js";
import { defineComponent, ref, watch, computed, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue"; import { defineComponent, ref, watch, computed, withCtx, createTextVNode, toDisplayString, useSSRContext } from "vue";
import { ssrRenderAttrs, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrLooseContain, ssrLooseEqual, ssrRenderList, ssrRenderClass, ssrRenderComponent } from "vue/server-renderer"; import { ssrRenderAttrs, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrLooseContain, ssrLooseEqual, ssrRenderList, ssrRenderClass, ssrRenderComponent } from "vue/server-renderer";
import "/app/node_modules/hookable/dist/index.mjs"; import "/app/node_modules/hookable/dist/index.mjs";
import { c as useCookie } from "../server.mjs"; import { d as useCookie } from "../server.mjs";
import "/app/node_modules/ufo/dist/index.mjs"; import "/app/node_modules/ufo/dist/index.mjs";
import "/app/node_modules/defu/dist/defu.mjs"; import "/app/node_modules/defu/dist/defu.mjs";
import "/app/node_modules/ofetch/dist/node.mjs"; import "/app/node_modules/ofetch/dist/node.mjs";
@@ -263,4 +263,4 @@ _sfc_main.setup = (props, ctx) => {
export { export {
_sfc_main as default _sfc_main as default
}; };
//# sourceMappingURL=index-CzKBE-M2.js.map //# sourceMappingURL=index-CemkpUgu.js.map

Some files were not shown because too many files have changed in this diff Show More