admin felület különválasztva

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

View File

@@ -0,0 +1,265 @@
# P0 Audit: 3D Capability Matrix (RBAC) — SQLAlchemy Model Audit Report
**Author:** Service Finder Rendszer-Architect
**Date:** 2026-06-24
**Gitea Issue:** #283
**Scope:** Read-only model analysis for 3-Dimensional RBAC system (Global Roles × Tenant Roles × Subscription Capabilities)
---
## Executive Summary
This report audits 4 critical dimensions against the existing SQLAlchemy model layer. The goal is to determine **what exists**, **what is missing**, and what **database migration steps** are needed to implement a full 3D Capability Matrix.
**Files examined:**
- `backend/app/models/identity/identity.py` — User/Person models
- `backend/app/models/marketplace/organization.py` — Organization, OrganizationMember, OrgRole models
- `backend/app/models/core_logic.py` — SubscriptionTier, OrganizationSubscription, UserSubscription models
- `backend/app/models/fleet_finance/models.py` — CostCategory model
- `backend/scripts/seed_finance_dictionaries.py` — CostCategory seed data
---
## DIMENSION 1: Global / System Roles
**Model:** `User``identity.users`
**Enum:** `UserRole`
### What EXISTS
| Field | Type | Details |
|---|---|---|
| `role` | `PG_ENUM(UserRole)` | System-level role: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER |
| `is_superuser` | **NOT FOUND** | There is no is_superuser boolean. Role-based only. |
| `custom_permissions` | `JSON` | Per-user permission overrides — `{}::jsonb` default |
| `scope_level` | `String(30)` | "individual" default — hierarchical scope context |
| `scope_id` | `String(50)` | The ID of the scope context |
| `is_vip` | `Boolean` | VIP flag |
| `subscription_plan` | `String(30)` | "FREE" default |
| `subscription_expires_at` | `DateTime` | Subscription expiry |
### Verdict
**Dimension 1 is sufficiently covered.** The `UserRole` enum provides 6 system-level roles. The `custom_permissions` JSON column allows per-user capability overrides. However, there is **no standardized capability/feature matrix** at the global level — everything is ad-hoc JSON.
---
## DIMENSION 2: Tenant / Local Roles
**Model:** `OrganizationMember``fleet.organization_members`
**Enum:** `OrgUserRole`
### What EXISTS
| Field | Type | Details |
|---|---|---|
| `role` | `PG_ENUM(OrgUserRole)` | OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER |
| `permissions` | `JSON` | Per-member capability flags — `{}::jsonb` |
| `is_permanent` | `Boolean` | Permanent member flag |
| `is_verified` | `Boolean` | Verified member flag |
| `status` | `String(20)` | "active" default |
### Bonus: Dynamic OrgRole RBAC System
**Model:** `OrgRole``fleet.org_roles`
| Field | Type | Details |
|---|---|---|
| `name_key` | `String(50)` | Unique role identifier (e.g., "OWNER") |
| `display_name` | `String(100)` | Human-readable name |
| `permissions` | `JSONB` | **Capability flags** per role — e.g. {"can_add_expense": true, "can_approve_expense": false} |
| `is_system` | `Boolean` | System role (cannot be deleted) |
| `priority` | `Integer` | Role hierarchy priority |
### Verdict
**Dimension 2 is well-covered.** The `OrgRole` model with its `permissions` JSONB column is exactly what Phase 2 RBAC needs. Each `OrganizationMember` also has an individual `permissions` JSON column for user-level overrides above the role.
---
## DIMENSION 3: Subscription Tier & Feature Capabilities
### 3A. SubscriptionTier Model
**Model:** `SubscriptionTier``system.subscription_tiers`
| Field | Type | Details |
|---|---|---|
| `name` | `String` | e.g., "free", "premium", "vip" |
| `rules` | `JSONB` | **Generic limits**: {"max_vehicles": 5} — NO standardized feature capability format |
| `is_custom` | `Boolean` | Custom tier flag |
### 3B. Organization Model (fleet schema)
| Field | Type | Details |
|---|---|---|
| `subscription_plan` | `String(30)` | "FREE" default — denormalized quick-ref |
| `subscription_expires_at` | `DateTime` | Subscription expiry |
| `subscription_tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
| `base_asset_limit` | `Integer` | Base vehicle limit (1 default) |
| `purchased_extra_slots` | `Integer` | Extra purchased vehicle slots |
| `settings` | `JSONB` | **Operational settings only** — NOT feature capabilities |
### 3C. OrganizationSubscription Model
**Model:** `OrganizationSubscription``finance.org_subscriptions`
| Field | Type | Details |
|---|---|---|
| `extra_allowances` | `JSONB` | Extra quotas: {"extra_vehicles": 1, "extra_garages": 1} |
| `tier_id` | `Integer (FK)` | References system.subscription_tiers.id |
### CRITICAL GAP: Missing custom_features / capabilities_override on Organization
**There is NO dedicated JSONB column** on the `Organization` model to store feature-level capability overrides like:
```json
{
"feature_fuel_logging": true,
"feature_gps_tracking": false,
"feature_analytics": true,
"feature_multi_driver": true,
"feature_service_booking": false,
"feature_tco_reports": false
}
```
The existing `settings` JSONB column is for *operational* settings, not *feature capability* gates.
Similarly, the `SubscriptionTier.rules` JSONB has **no standardized format** for feature capability flags — it only stores numeric limits.
---
## DIMENSION 4: Cost Category Feature Linkage
**Model:** `CostCategory``fleet_finance.cost_categories`
| Field | Type | Details |
|---|---|---|
| `min_tier` | `String(20)` | "free" default — minimum subscription tier to access this category |
| `visibility` | `String(20)` | "both" default — visibility scope |
| `is_system` | `Boolean` | System category flag |
| `required_feature` | **NOT FOUND** | No column to tie category to a specific subscription feature |
| `min_capability` | **NOT FOUND** | No column for minimum capability requirement |
### Verdict
**Partially covered.** The `min_tier` field gates categories by subscription tier (e.g., "free", "premium"), but there is **no way to gate by individual feature capability**. For example, we cannot say "this category requires the feature_fuel_logging capability."
---
## 3D Capability Matrix — Current vs. Required
| Dimension | What We Have | What We Need |
|---|---|---|
| D1: Global Roles | UserRole enum with 6 values + custom_permissions JSON | Sufficient. No DB changes needed. |
| D2: Tenant Roles | OrgUserRole enum + OrgRole.permissions JSONB + per-member permissions JSON | Sufficient. No DB changes needed. |
| D3a: Subscription Tiers | SubscriptionTier.rules JSONB (generic limits) | Need standardized feature capability format |
| D3b: Org Feature Overrides | Organization.settings JSONB (operational only) | **NEED custom_features JSONB column** |
| D4: Cost Category Gating | CostCategory.min_tier (tier-level only) | Need required_feature column + feature-based gating |
---
## Proposed Database Migration Steps
The following migrations must be executed **in order** to build the 3D Capability Matrix architecture:
### Migration 1: Add feature_capabilities JSONB to SubscriptionTier
**Target:** `SubscriptionTier``system.subscription_tiers`
```sql
ALTER TABLE system.subscription_tiers
ADD COLUMN feature_capabilities JSONB NOT NULL DEFAULT '{}'::jsonb;
```
**Purpose:** Define which features each tier enables. Example:
| Tier | Feature Capabilities |
|---|---|
| free | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_service_reminders": false, "feature_gps_tracking": false} |
| premium | {"feature_fuel_logging": true, "feature_basic_reports": true, "feature_analytics": true, "feature_gps_tracking": true, "feature_multi_driver": true} |
| vip | All features enabled |
### Migration 2: Add custom_features JSONB to Organization
**Target:** `Organization``fleet.organizations`
```sql
ALTER TABLE fleet.organizations
ADD COLUMN custom_features JSONB NOT NULL DEFAULT '{}'::jsonb;
```
**Purpose:** Allow per-organization feature overrides. These overrides **merge on top of** the tier defaults. A `false` value can disable a tier feature for a specific org.
### Migration 3: Add required_feature to CostCategory
**Target:** `CostCategory``fleet_finance.cost_categories`
```sql
ALTER TABLE fleet_finance.cost_categories
ADD COLUMN required_feature VARCHAR(50) DEFAULT NULL;
```
**Purpose:** Tie a cost category to a specific feature capability.
| Category | required_feature |
|---|---|
| OPERATION_FUEL | NULL (free for all) |
| SERVICE_SCHEDULED | NULL (free for all) |
| FINANCING_LEASE | "feature_financing" |
| INSURANCE_CASCO | "feature_insurance" |
| ANALYTICS_TCO | "feature_analytics" |
### Migration 4 (Optional): Create FeatureCapability Enum
```sql
CREATE TYPE system.feature_capability AS ENUM (
'feature_fuel_logging',
'feature_basic_reports',
'feature_analytics',
'feature_gps_tracking',
'feature_multi_driver',
'feature_service_booking',
'feature_tco_reports',
'feature_financing',
'feature_insurance',
'feature_ad_free'
);
```
**Purpose:** Standardize feature names across the system.
---
## Capability Resolution Algorithm (Proposed)
The final capability check for any feature at runtime follows this resolution chain:
```
1. Organization.custom_features["feature_xxx"] <- explicit override (highest priority)
2. SubscriptionTier.feature_capabilities["feature_xxx"] <- tier default
3. false <- implicit default (feature not available)
```
**For CostCategory visibility:**
```
If category.min_tier > org.subscription_plan -> HIDE
If category.required_feature IS NOT NULL AND NOT resolved_feature_capability -> HIDE
Otherwise -> SHOW
```
---
## Summary
| Component | Status | Action Required |
|---|---|---|
| D1: Global Roles | Complete | None |
| D2: Tenant Roles | Complete | None |
| D3: Subscription Tier Features | Partial | Add feature_capabilities JSONB to SubscriptionTier |
| D3: Org Feature Overrides | Missing | Add custom_features JSONB to Organization |
| D4: Cost Category Feature Gate | Partial | Add required_feature column to CostCategory |
| Standardization | Missing | (Optional) Create FeatureCapability Enum |
**Total proposed DB changes: 3 ALTER TABLE statements + 1 optional ENUM type.**

View File

@@ -0,0 +1,266 @@
# P0 Architect Report: Backoffice SSO & Router Architecture
**Date:** 2026-06-24
**Scope:** Cross-subdomain cookie SSO, backend RBAC dependencies, frontend admin routing
**Auditor:** Fast Coder (Core Developer)
---
## 1. AUDIT: Cross-Subdomain Cookies (Backend Auth)
### File: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py)
#### Current State (Lines 127134, 221228)
Both the `/login` and `/verify-email` endpoints set the `refresh_token` cookie like this:
```python
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
max_age=max_age_sec
)
```
#### ❌ Finding: `domain` parameter is MISSING
The `domain` parameter is **not set** on either `set_cookie()` call. This means the cookie is scoped to the **exact origin** that issued it.
**Impact for SSO:**
- If the user logs in at `app.servicefinder.hu`, the `refresh_token` cookie is only sent to `app.servicefinder.hu`.
- When the user navigates to `admin.servicefinder.hu`, the browser **will not send** the cookie because the domain doesn't match.
- This breaks cross-subdomain SSO entirely — the admin subdomain cannot silently refresh the access token.
#### Required Fix
Add `domain=".servicefinder.hu"` (with leading dot for subdomain wildcard) to both `set_cookie()` calls:
```python
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=".servicefinder.hu", # ← ADD THIS
max_age=max_age_sec
)
```
**Considerations:**
- The domain should be **dynamic** based on environment (e.g., `.servicefinder.hu` for production, `localhost` for dev).
- Use `settings.COOKIE_DOMAIN` or derive it from `settings.FRONTEND_BASE_URL`.
- `samesite="lax"` is correct — it allows the cookie to be sent on top-level navigations between subdomains.
---
## 2. AUDIT: Backend Dependencies (Deps for Staff)
### File: [`backend/app/api/deps.py`](backend/app/api/deps.py)
#### 2a. Existing `get_current_admin` (Lines 160177)
```python
async def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
allowed_roles = {
UserRole.SUPERADMIN,
UserRole.ADMIN,
UserRole.MODERATOR,
}
if current_user.role not in allowed_roles:
raise HTTPException(status_code=403, detail="...")
return current_user
```
**✅ Good:** This already exists and allows `SUPERADMIN`, `ADMIN`, and `MODERATOR`.
**❌ Gap:** It does **not** include `SALES_REP` or `SERVICE_MGR`. The Architect's vision is that the Backoffice (`admin.servicefinder.hu`) should admit all "staff" roles — not just superadmins.
#### 2b. Existing `RequireSystemCapability` (Lines 184218)
```python
def RequireSystemCapability(capability_name: str):
async def system_capability_checker(current_user: User = Depends(get_current_user)) -> bool:
if current_user.role == UserRole.SUPERADMIN:
return True
role_key = current_user.role.value
if not role_has_capability(role_key, capability_name):
raise HTTPException(status_code=403, detail=f"...")
return True
return system_capability_checker
```
**✅ Good:** This is the scalable, fine-grained approach. Each endpoint can declare exactly which capability it needs.
**❌ Gap:** There is no **middle-ground** dependency — something between `get_current_admin` (hardcoded role set) and `RequireSystemCapability` (fine-grained). We need a `RequireRole` dependency that accepts a list of allowed roles.
#### 2c. Proposed: `RequireRole` Dependency
Add to [`backend/app/api/deps.py`](backend/app/api/deps.py):
```python
def RequireRole(allowed_roles: List[UserRole]):
"""
🎯 Flexible role-checking dependency.
Accepts a list of UserRole values. If the current user's role is
in the list, access is granted. Otherwise, 403 Forbidden.
Usage:
@router.get("/admin/sales")
async def sales_dashboard(
current_user: User = Depends(get_current_user),
_ = Depends(RequireRole([UserRole.SUPERADMIN, UserRole.SALES_REP]))
):
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> bool:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AUTH.INSUFFICIENT_PERMISSIONS"
)
return True
return role_checker
```
And a **convenience** `get_current_staff` that admits all internal staff:
```python
STAFF_ROLES = {
UserRole.SUPERADMIN,
UserRole.ADMIN,
UserRole.MODERATOR,
UserRole.SALES_REP,
UserRole.SERVICE_MGR,
}
async def get_current_staff(
current_user: User = Depends(get_current_user)
) -> User:
if current_user.role not in STAFF_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="AUTH.STAFF_ONLY"
)
return current_user
```
---
## 3. AUDIT: Frontend Architecture (Admin Domain Routing)
### File: [`frontend/src/router/index.ts`](frontend/src/router/index.ts)
#### 3a. Current Admin Route (Lines 112137)
```typescript
{
path: '/admin',
component: () => import('../layouts/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{ path: '', name: 'admin-dashboard', component: () => import('../views/admin/AdminDashboardView.vue') },
{ path: 'users', name: 'admin-users', component: () => import('../views/admin/AdminUsersView.vue') },
{ path: 'packages', name: 'admin-packages', component: () => import('../views/admin/AdminPackagesView.vue') },
{ path: 'services', name: 'admin-services', component: () => import('../views/admin/AdminServicesView.vue') },
]
}
```
**✅ Good:** The admin routes exist under a single `/admin` path with `requiresAdmin` meta.
#### 3b. Current Route Guard (Lines 166177)
```typescript
if (to.meta.requiresAdmin) {
const role = authStore.user?.role
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
if (!role || !adminRoles.includes(role.toUpperCase())) {
return next('/dashboard')
}
}
```
**❌ Finding: The guard is too restrictive.** It only admits `SUPERADMIN`, `ADMIN`, and `MODERATOR`. `SALES_REP` and `SERVICE_MGR` are blocked from the admin panel entirely.
**❌ Finding: No separate subdomain routing.** The admin UI is served under the same SPA at `/admin` path, not as a separate deployment for `admin.servicefinder.hu`. This means:
- The same Vite build serves both `app.servicefinder.hu` and `admin.servicefinder.hu`.
- The Nginx reverse proxy must route `admin.servicefinder.hu` → same SPA, but the router handles the `/admin` prefix.
- This is **fine for Phase 1** but limits independent scaling/deployment.
#### 3c. Auth Store `isAdmin` Getter (Lines 103110)
```typescript
const isAdmin = computed(() => {
const role = user.value?.role
if (!role) return false
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
return adminRoles.includes(role.toUpperCase())
})
```
**❌ Same gap:** `SALES_REP` and `SERVICE_MGR` are not recognized as admin/staff.
#### 3d. AdminLayout Component
**File:** [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue)
**✅ Good:** The layout has a proper sidebar with navigation links, user profile section, and slot for header controls. It references `LanguageSwitcher`, `ModeSwitcher`, and `HeaderProfile`.
**❌ Finding:** The sidebar links include routes that don't exist in the router (`/admin/vehicles`, `/admin/translations`, `/admin/system`, `/admin/audit`). These will produce 404s when clicked.
---
## 4. ACTION PLAN
### Phase 1: Fix Cookie Domain for SSO (Backend)
| # | Task | File | Details |
|---|------|------|---------|
| 1.1 | Add `COOKIE_DOMAIN` to settings | [`backend/app/core/config.py`](backend/app/core/config.py) | Add `COOKIE_DOMAIN: str = ".servicefinder.hu"` field |
| 1.2 | Add `domain` param to `/login` cookie | [`backend/app/api/v1/endpoints/auth.py:127`](backend/app/api/v1/endpoints/auth.py:127) | `response.set_cookie(..., domain=settings.COOKIE_DOMAIN)` |
| 1.3 | Add `domain` param to `/verify-email` cookie | [`backend/app/api/v1/endpoints/auth.py:221`](backend/app/api/v1/endpoints/auth.py:221) | Same fix |
### Phase 2: Create Scalable Backend Dependencies
| # | Task | File | Details |
|---|------|------|---------|
| 2.1 | Add `RequireRole` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Generic role-list checker |
| 2.2 | Add `get_current_staff` dependency | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Admits SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR |
| 2.3 | Update `get_current_admin` to use STAFF_ROLES | [`backend/app/api/deps.py`](backend/app/api/deps.py) | Or keep as-is for superadmin-only endpoints |
### Phase 3: Update Frontend Router Guard & Auth Store
| # | Task | File | Details |
|---|------|------|---------|
| 3.1 | Update `requiresAdmin` guard to admit all staff roles | [`frontend/src/router/index.ts:168`](frontend/src/router/index.ts:168) | Change `adminRoles` to include `SALES_REP`, `SERVICE_MGR` |
| 3.2 | Update `isAdmin` getter in auth store | [`frontend/src/stores/auth.ts:103`](frontend/src/stores/auth.ts:103) | Same role expansion |
| 3.3 | Add missing admin routes or remove dead sidebar links | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | Add routes for vehicles, translations, system, audit — or remove sidebar links |
| 3.4 | Consider role-based sidebar visibility | [`frontend/src/layouts/AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) | SALES_REP should only see sales-related nav items |
### Phase 4: Nginx / Reverse Proxy (Infrastructure)
| # | Task | Details |
|---|------|---------|
| 4.1 | Ensure `admin.servicefinder.hu` → same SPA | Nginx config must serve the same frontend build for both subdomains |
| 4.2 | Ensure CORS allows `admin.servicefinder.hu` | Already in [`backend/app/core/config.py:93`](backend/app/core/config.py:93) ✅ |
---
## Summary of Findings
| Area | Status | Issue |
|------|--------|-------|
| Cookie `domain` | ❌ Missing | SSO broken between subdomains |
| `get_current_admin` | ⚠️ Too narrow | Missing SALES_REP, SERVICE_MGR |
| `RequireSystemCapability` | ✅ Good | Fine-grained, scalable |
| `RequireRole` | ❌ Missing | No flexible role-list dependency |
| Frontend `requiresAdmin` guard | ⚠️ Too narrow | Blocks SALES_REP, SERVICE_MGR |
| Frontend `isAdmin` getter | ⚠️ Too narrow | Same gap |
| AdminLayout sidebar links | ❌ Dead links | Routes for vehicles/translations/system/audit don't exist |
| CORS origins | ✅ Good | `admin.servicefinder.hu` already whitelisted |

View File

@@ -0,0 +1,366 @@
# 🚨 P0 READ-ONLY AUDIT - Tenant Isolation & Vehicle Mapping Report
**Dátum:** 2026-06-23
**Auditor:** Rendszer-Architect (DeepSeek Reasoner)
**Státusz:** Elkészült
**Scope:** Full-stack (Database → Backend API → Frontend)
---
## 🔍 Executive Summary
A vizsgálat célja a `CostsView.vue` jármű legördülő menüjében (vehicle dropdown) tapasztalt **tenant izolációs hiba** (Garage Isolation) kivizsgálása volt. A hibajelenség: amikor a felhasználó garázst/szervezetet vált, a költségek helyesen frissülnek, de a jármű legördülő lista a **régi garázs összes járművét** mutatja továbbra is.
**Verdikt:** A backend API helyesen szűr (`current_organization_id` alapján, JWT `scope_id`-n keresztül). A hiba gyökere **kizárólag a frontend cache elavulásában** (cache staleness) rejlik: a járművek csak egyszer töltődnek be (`onMounted`), és soha nem frissülnek garázsváltáskor.
---
## 📊 STEP 1: Database Data Mapping (tester_pro, User ID: 28)
### 1.1 User Adatok
| Mező | Érték |
|------|-------|
| `id` | 28 |
| `email` | `tester_pro@profibot.hu` |
| `role` | `ADMIN` |
| `scope_id` | `null` (personal mode) |
| `scope_level` | `individual` |
| `subscription_plan` | `PREMIUM` |
| `ui_mode` | `personal` |
| `is_active` | `true` |
### 1.2 Organization Tagságok
| Org ID | Név | Típus | Szerepkör | Státusz |
|--------|-----|-------|-----------|---------|
| **1** | `Test Company` | `individual` | `OWNER` | `active`, `verified` |
| **21** | `Private_28` | `individual` | `OWNER` | `active`, `verified` |
### 1.3 Járművek (Asset-ek) Szervezetenként
#### 🏢 **Org 1 - Test Company** (19 jármű)
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|----------|-------|--------|-----|---------|-------------|
| `BMW123` | BMW | BMWM3 | - | `active` | `verified` |
| `QWE432` | BMW | R 1250 GS ADVENTURE | - | `active` | `verified` |
| `TEST-000` | Ford | Focus | TESTVIN1111111111 | `active` | `verified` |
| `TEST-111` | Ford | Focus | TESTVIN1111111119 | `active` | `verified` |
| `TEST-123` | Ford | Focus | TESTVIN1234567890 | `active` | `draft` |
| `TEST-555` | Ford | Focus | TESTVIN5555555559 | `active` | `verified` |
| `TEST-777` | Ford | Focus | TESTVIN7777777779 | `active` | `verified` |
| `TEST-888` | Ford | Focus | TESTVIN8888888889 | `active` | `draft` |
| `TEST-999` | Ford | Focus | TESTVIN9999999999 | `active` | `draft` |
| `TEST-API-999` | TestBrand | TestModel | - | `active` | `draft` |
| `DRAFT-000` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-111` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-456` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-555` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-777` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-888` | Ismeretlen | - | - | `draft` | `draft` |
| `DRAFT-999` | Ismeretlen | - | - | `draft` | `draft` |
| `TEST123` | TestBrand | TestModel | TESTVIN123456789 | `draft` | `draft` |
| `HGV-TEST-01` | MERCEDES-BENZ | ACTROS | - | `archived` | `verified` |
#### 🏠 **Org 21 - Private_28** (6 jármű)
| Rendszám | Márka | Modell | VIN | Státusz | Data Status |
|----------|-------|--------|-----|---------|-------------|
| `ABC-123` | Toyota | Corolla | 1HGCM82633A123456 | `active` | `verified` |
| `AIML519` | Skoda | Citigo e | - | `active` | `enriched` |
| `PKT215` | Mazda | 2 | - | `active` | `draft` |
| `QWE123` | APRILIA | af1 | - | `active` | `draft` |
| `TTRFGHZT` | BAYLINER | 1750 CAPRY BOWRIDER | - | `active` | `draft` |
| `UOK795` | Honda | CB1000R | ZDCSC60C0FF091488 | `active` | `verified` |
**Összesen:** **25 jármű** a két szervezetben (10+6 aktív, 8 draft, 1 archived).
---
## 🏗️ STEP 2: Backend API Endpoint Audit
### Végpont: `GET /assets/vehicles`
**Fájl:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:229)
**Szignatúra:**
```python
@router.get("/vehicles", response_model=List[AssetResponse])
async def get_user_vehicles(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
current_user: AuthContext = Depends(get_current_user)
):
```
**Függőségek:** A végpont **nem** fogad el `organization_id` query paramétert a frontendtől. Kizárólag a JWT tokenben tárolt `current_user.scope_id` alapján szűr.
### Szűrési Logika
#### 1⃣ Personal Mode (nincs scope_id beállítva) - [`assets.py:259`](backend/app/api/v1/endpoints/assets.py:259)
```python
if current_user.scope_id is None:
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in result.fetchall()]
stmt = select(Asset).where(
Asset.current_organization_id.in_(user_org_ids),
Asset.status == "active"
)
```
**Mit csinál:** Personal módban lekéri az összes szervezet ID-t, ahol a user tag, majd visszaadja az összes olyan szervezet összes aktív járművét. Ez a **teljes flotta** a user szemszögéből.
#### 2⃣ Corporate Mode (scope_id beállítva) - [`assets.py:286`](backend/app/api/v1/endpoints/assets.py:286)
```python
else:
scope_org_id = int(current_user.scope_id)
stmt = select(Asset).where(
Asset.current_organization_id == scope_org_id,
Asset.status == "active"
)
```
**Mit csinál:** Corporate módban kizárólag a `scope_id`-ban megadott szervezet járműveit adja vissza. Ez a helyes, izolált viselkedés.
### Backend Audit Verdict: ✅ **Helyes**
A backend API logikája megfelelő. A probléma **nem** a backendben van.
---
## 🖥️ STEP 3: Frontend Audit - CostsView.vue & Stores
### 3.1 A Hibás Működés Rekonstrukciója
#### ✅ Amit a `cost.ts` store jól csinál - [`cost.ts:71`](frontend/src/stores/cost.ts:71)
A költség létrehozásánál defense-in-depth jelleggel injektálja az `active_organization_id`-t:
```typescript
const userOrgId = useAuthStore().user?.active_organization_id
if (userOrgId != null) {
body.organization_id = userOrgId
}
```
#### ✅ Amit a `CostsView.vue` jól csinál - [`CostsView.vue:435`](frontend/src/views/costs/CostsView.vue:435)
A `fetchCosts()` függvény helyesen adja át az `active_organization_id`-t a backendnek:
```typescript
const params: Record<string, any> = { page: currentPage.value, per_page: itemsPerPage.value }
const userOrgId = authStore.user?.active_organization_id
if (userOrgId != null) {
params.organization_id = userOrgId
}
```
### 3.2 A Hiba Gyökere: Cache Staleness
#### ❌ **1. hiba:** A járművek csak egyszer töltődnek be - [`CostsView.vue:559`](frontend/src/views/costs/CostsView.vue:559)
```typescript
onMounted(() => {
if (vehicleStore.vehicles.length === 0) {
vehicleStore.fetchVehicles() // <-- CSAK egyszer, onMounted-ben!
}
fetchCosts()
})
```
A járművek **csak akkor** töltődnek be, ha a store még üres. Ez azt jelenti, hogy ha a user már járt a Costs oldalon, a járművek a Pinia store-ban cache-elődnek, és **soha többé nem frissülnek**.
#### ❌ **2. hiba:** Nincs watch az org váltásra - [`CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
```typescript
watch(
() => authStore.user?.active_organization_id,
() => {
currentPage.value = 1
fetchCosts() // <-- Csak költségeket frissíti
// 🔴 HIÁNYZIK: vehicleStore.fetchVehicles() !
}
)
```
Amikor a user garázst vált, a `watch` csak a költségeket frissíti. A járművek **nem** frissülnek.
#### ❌ **3. hiba:** `fetchVehicles()` nem fogad el org_id paramétert - [`vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
```typescript
async function fetchVehicles() {
isLoading.value = true
error.value = null
try {
const res = await api.get('/assets/vehicles') // <-- NINCS org_id param
vehicles.value = res.data as Vehicle[]
} catch (err: any) { ... }
}
```
Még ha meg is hívnánk a `fetchVehicles()`-t org váltáskor, a függvény jelenleg nem tud szűrni - minden esetben a teljes (scope-alapú) listát kéri le. Szerencsére a backend JWT `scope_id`-t használ, így ha újrahívjuk, a backend a helyes (már frissített scope) alapján adja vissza az adatokat.
### 3.3 A Szivárgás Vizualizációja
```mermaid
sequenceDiagram
participant User
participant CostsView
participant vehicleStore
participant authStore
participant Backend
Note over User,Backend: === Initial Load (onMounted) ===
User->>CostsView: Navigates to Costs page
CostsView->>vehicleStore: fetchVehicles() [no org filter]
vehicleStore->>Backend: GET /assets/vehicles [scope_id=null]
Backend-->>vehicleStore: [25 vehicles from ALL orgs]
vehicleStore->>CostsView: vehicles cached in Pinia
Note over User,Backend: === Org Switch ===
User->>CostsView: Switches to Org 21 (Private_28)
CostsView->>authStore: switchOrganization(21)
authStore->>Backend: PATCH /users/me/active-organization
Note over CostsView: WATCH triggers
CostsView->>CostsView: fetchCosts() with org_id=21 ✅
CostsView->>CostsView: 🔴 DOES NOT refresh vehicles!
Note over CostsView: Vehicle dropdown shows ALL 25 vehicles<br/>(from Test Company AND Private_28)
Note over CostsView: But expenses are correctly filtered to Org 21 only!
```
---
## 🎯 STEP 4: Javítási Javaslat (Fix Recommendation)
### Hiba Kategorizálás
| Kritérium | Érték |
|-----------|-------|
| **Típus** | Frontend Cache Staleness (Pinia store nem frissül garázsváltáskor) |
| **Súlyosság** | **P0 - Kritikus** (adat szivárgás: user látja más garázs járműveit) |
| **Érintett fájlok** | `CostsView.vue`, `vehicle.ts` |
| **Backend érintettség** | ❌ Nincs (backend helyesen szűr) |
| **Adatbázis érintettség** | ❌ Nincs (adatok helyesen vannak tárolva) |
### Javítási Terv
#### 1. Kötelező javítás: `CostsView.vue` watch kiegészítése
Fájl: [`frontend/src/views/costs/CostsView.vue:549`](frontend/src/views/costs/CostsView.vue:549)
**Jelenleg:**
```typescript
watch(
() => authStore.user?.active_organization_id,
() => {
currentPage.value = 1
fetchCosts()
}
)
```
**Javítás után:**
```typescript
watch(
() => authStore.user?.active_organization_id,
() => {
currentPage.value = 1
vehicleStore.fetchVehicles() // <-- ÚJ: Járművek frissítése org váltáskor
fetchCosts()
}
)
```
#### 2. Ajánlott javítás: `vehicle.ts` - add org_id paraméter támogatás
Fájl: [`frontend/src/stores/vehicle.ts:141`](frontend/src/stores/vehicle.ts:141)
**Javaslat:** Bár a backend JWT scope alapján is szűr, érdemes explicit `organization_id` query paraméter támogatást hozzáadni:
```typescript
async function fetchVehicles(organizationId?: number | null) {
isLoading.value = true
error.value = null
try {
const params: Record<string, any> = {}
if (organizationId != null) {
params.organization_id = organizationId
}
const res = await api.get('/assets/vehicles', { params })
vehicles.value = res.data as Vehicle[]
} catch (err: any) { ... }
}
```
#### 3. Opcionális javítás: `vehicleStore` automatikus cache invalidáció
Fájl: [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts)
Adj hozzá egy `watch`-t a `vehicle` store-ban, ami automatikusan frissíti a járműveket, ha a user `active_organization_id`-ja megváltozik:
```typescript
// vehicle.ts store-ban
watch(
() => useAuthStore().user?.active_organization_id,
() => {
fetchVehicles() // Automatikus cache invalidáció org váltáskor
}
)
```
#### 4. Opcionális javítás: Backend `GET /assets/vehicles` endpoint bővítése
Fájl: [`backend/app/api/v1/endpoints/assets.py:229`](backend/app/api/v1/endpoints/assets.py:229)
Adj hozzá egy opcionális `organization_id` query paramétert, ami felülbírálja a JWT `scope_id`-t (admin/jogosultság ellenőrzéssel):
```python
@router.get("/vehicles", response_model=List[AssetResponse])
async def get_user_vehicles(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
organization_id: Optional[int] = Query(None), # <-- ÚJ paraméter
db: AsyncSession = Depends(get_db),
current_user: AuthContext = Depends(get_current_user)
):
```
---
## 📋 Összefoglalás
### Valóság vs. Elvárt Működés
| Aspektus | Elvárt | Valóság |
|----------|--------|---------|
| `tester_pro` garázsai | Csak az aktív garázs adatai | Mindkét garázs (Test Company + Private_28) járművei látszanak |
| Költségek szűrése | Csak az aktív garázsra | ✅ Helyes - org_id alapján szűr |
| Járművek szűrése | Csak az aktív garázsra | ❌ Hibás - nincs org_id filter, cache-ből jön |
| Backend API | Helyes scope alapú szűrés | ✅ Helyes - JWT scope_id alapján dolgozik |
| Adatbázis | Helyes org-hozzárendelés | ✅ Helyes - minden asset.current_organization_id be van állítva |
### Következtetés
A tenant izolációs hiba **kizárólag frontend oldali**. A backend API és az adatbázis séma megfelelő. A javításhoz elegendő a `CostsView.vue` `watch` blokkját kiegészíteni egy `vehicleStore.fetchVehicles()` hívással. A többi javítási javaslat (backend paraméter, store szintű watch) opcionális, de ajánlott a robosztusság növeléséhez.
---
## 🔗 Referenciák
- [CostsView.vue](frontend/src/views/costs/CostsView.vue) - A hibás watch blokk (549-556) és onMounted (558-565)
- [vehicle.ts store](frontend/src/stores/vehicle.ts) - A fetchVehicles() függvény (141-158)
- [auth.ts store](frontend/src/stores/auth.ts) - switchOrganization() (687) és active_organization_id (70)
- [cost.ts store](frontend/src/stores/cost.ts) - Defense-in-depth org_id injektálás (71)
- [assets.py (backend)](backend/app/api/v1/endpoints/assets.py) - GET /assets/vehicles (229-318)
---
*Audit lezárva: 2026-06-23 22:12 UTC*

View File

@@ -0,0 +1,301 @@
# P0 Audit Report: UI, Switcher, Roles & Subscription Visibility
**Date:** 2026-06-23
**Author:** Fast Coder (Core Developer)
**Status:** Read-Only Audit Complete — No Code Modified
---
## 1. AUDIT: Garage Switcher (`HeaderCompanySwitcher.vue`)
### File: [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)
### Current Behavior
The switcher fetches organizations via `authStore.fetchMyOrganizations()` which calls `GET /api/v1/organizations/my`.
**Filtering logic (line 144-151):**
```typescript
const companyOrganizations = computed(() => {
return authStore.myOrganizations.filter(
(org) => org.org_type &&
org.org_type !== 'individual' &&
org.org_type !== 'service_provider' &&
org.org_type !== 'service'
)
})
```
**Root Cause of Missing Private Garages:**
- The `org_type` field in the backend response (`GET /organizations/my`) returns the actual `OrgType` enum value.
- The filter **excludes** `individual` type organizations — but a user's **private/personal garage** IS of type `individual`.
- The switcher only shows the **"Private Garage" button** (line 40-58) as a hardcoded navigation to `/dashboard`, but does NOT list individual-type organizations in the dropdown.
- If a user has **multiple** private/individual garages (e.g., "Saját garázs" + "Családi garázs"), only the first one is accessible via the hardcoded button. The others are invisible.
**Why company garages appear correctly:**
- Business/fleet_owner type organizations pass the filter and are listed dynamically.
### Required Fix
1. **Remove the `org_type !== 'individual'` filter** — or change the approach to show ALL organizations.
2. **Add visual separation** in the dropdown: group by `org_type` (e.g., "Saját garázsok" vs "Céges garázsok").
3. **Ensure `org_type` is always populated** in the backend response (currently it's optional in the `OrganizationItem` type).
---
## 2. AUDIT: Subscription & Limit Visibility
### 2a. Backend: `GET /api/v1/organizations/my`
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:180)
**Current response includes:**
- `subscription_plan` (string, e.g. "test_privát")
- `subscription_expires_at` (ISO datetime)
- `max_vehicles` (P0: from subscription tier JSONB rules)
- `max_garages` (P0: from subscription tier JSONB rules)
- `user_role` (OWNER/ADMIN/etc.)
**Verdict:** ✅ Backend already returns all necessary subscription data per organization.
### 2b. Backend: `GET /auth/me` (User Profile)
**File:** [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:48)
**Current response includes:**
- `subscription_plan` (user-level)
- `max_vehicles`, `max_garages` (from subscription tier)
- `system_capabilities`, `org_capabilities`
**Verdict:** ✅ Backend already returns user-level subscription data.
### 2c. Frontend: `SubscriptionStatusWidget.vue`
**File:** [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue)
**Current display:**
- Shows plan name (`subscriptionPlan`)
- Shows days remaining
- Shows a progress bar (but only for time remaining, NOT for vehicle count)
**Missing:**
-**No vehicle count display** (e.g., "16/100 jármű")
-**No max_vehicles limit progress bar**
- The widget only shows the **time-based** progress, not the **usage-based** progress.
### 2d. Frontend: `SubscriptionInfoModal.vue`
**File:** [`frontend/src/components/subscription/SubscriptionInfoModal.vue`](frontend/src/components/subscription/SubscriptionInfoModal.vue)
**Current display:**
- ✅ Shows plan name
- ✅ Shows expiry date + days remaining
- ✅ Shows vehicle limit progress bar (`vehicleCount / maxVehicles`)
- ✅ Shows garage limit progress bar (`garageCount / maxGarages`)
**Verdict:** ✅ The modal already has full subscription visibility. The issue is that this modal is only accessible via a button click, not visible on the dashboard by default.
### 2e. Frontend: `DashboardView.vue`
**File:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:78-94)
**Current display:**
-`SubscriptionStatusWidget` is rendered in the dashboard (line 81)
- ❌ But it only shows plan name + days remaining, NOT vehicle usage
### Required Fixes
1. **Enhance `SubscriptionStatusWidget.vue`** to also show:
- Vehicle count vs limit (e.g., "🚗 16/100")
- A secondary progress bar for vehicle usage
- The plan name should be more prominent
2. **Alternatively**, replace the widget with a richer component that includes both time and usage data.
---
## 3. AUDIT: Role Logic & Ownership Transfer
### 3a. Backend: Role-Based Access Control
**File:** [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:329-357)
**`_check_org_admin_access()` function:**
- Allows both `OWNER` and `ADMIN` roles to access admin endpoints
- Used by: `GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`
- ✅ ADMINs already have full operational rights (member management, invitations)
**Subscription management endpoint:**
- `PUT /{org_id}/subscription` (line 775) requires `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` — this is a **system-level** capability, not org-level.
-**ADMINs cannot change subscription** via this endpoint unless they also have the system capability (which typically only SUPERADMIN has).
- The Architect's requirement says ADMINs should manage billing/subscription. This needs a separate org-level permission check.
### 3b. Backend: Ownership Transfer
**File:** [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:196)
- `is_ownership_transferable` field exists on the `Organization` model (boolean, default `true`)
- `can_transfer_ownership` capability exists in `seed_org_roles.py` (line 35: OWNER=true, ADMIN=false)
-**No dedicated backend endpoint** for "Transfer Ownership" exists yet
- The `PATCH /{org_id}/members/{member_id}` endpoint (line 486) can change a member's role to OWNER, but there's no complete transfer flow (e.g., reassign `owner_id` on the Organization record, log the transfer, notify parties)
### 3c. Frontend: Members/Team Page
-**No frontend page** found for managing team members (search for "members", "team", "munkatárs", "tagok" returned 0 results in `.vue` files)
- The backend has all the APIs (`GET /{org_id}/members`, `POST /{org_id}/invitations`, `PATCH /{org_id}/members/{member_id}`, `DELETE /{org_id}/members/{member_id}`), but there's no UI to call them
### Required Fixes
1. **Create a Team Members page** (frontend) that uses the existing backend APIs
2. **Add org-level subscription management** for ADMINs (separate permission check from system-level)
3. **Create a Transfer Ownership endpoint** + UI flow
---
## 4. AUDIT: Mobile Menu Alignment (Hamburger Menu)
### File: [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75)
**Current "Költségek" button styling:**
```html
<button
@click="navigateToCard('costs')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" ...>
{{ t('menu.finance') }}
</button>
```
**Analysis:**
- The button uses `flex items-center gap-3` — this is a flex row with the icon and text.
- The text is **not** wrapped in any alignment utility — it uses default flex alignment.
- The icon + text are left-aligned within the flex container (default `justify-start`).
- The `text-sm` class applies to the text, which is fine.
- **There is NO `text-center` class** on this button or its text. The Architect's report mentions `text-center` causing misalignment, but the current code uses `flex items-center gap-3` which is correct for left-aligned menu items.
**However**, looking at the `OrganizationLayout.vue` hamburger menu (line 40-72), the same pattern is used — all buttons are `flex w-full items-center gap-3`. The text alignment is consistent.
**Possible issue:** The problem might be in a different component or a different version of the file. The current code appears correctly aligned. If there's a visual bug, it could be:
1. A CSS conflict from a parent component
2. An i18n translation string that's too long causing text wrapping
3. A different mobile menu component not found in this search
### Required Fix
- If the issue is confirmed, ensure all hamburger menu buttons use `flex items-center gap-3` (already the case)
- Check if any parent CSS overrides the alignment
- Verify the `menu.finance` translation key produces the expected text
---
## 5. ARCHITECT REPORT & ACTION PLAN
### Summary of Findings
| # | Area | Status | Priority |
|---|------|--------|----------|
| 1 | Garage Switcher: individual orgs filtered out | ❌ Bug | **P0** |
| 2 | SubscriptionStatusWidget: missing vehicle usage | ❌ Missing | **P0** |
| 3 | Team Members UI page | ❌ Missing | **P1** |
| 4 | Ownership Transfer endpoint | ❌ Missing | **P1** |
| 5 | ADMIN subscription management | ⚠️ Partial | **P1** |
| 6 | Mobile menu alignment | ✅ OK (current code) | **P2** |
### Detailed Action Plan
#### 🔴 P0 — Must Fix
**Task 1: Fix Garage Switcher to show ALL organizations**
| File | Change |
|------|--------|
| [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue:144) | Remove the `org_type !== 'individual'` filter. Instead, show ALL organizations grouped by type (individual vs business). Add a section header "Saját garázsok" for individual orgs. |
| [`frontend/src/types/organization.ts`](frontend/src/types/organization.ts:24) | Make `org_type` required (remove `?`) since the backend now sends it reliably |
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:239-269) | Verify `org_type` is always populated in the response (currently uses `o.org_type.value if hasattr(...)` — ensure it never returns null) |
**Task 2: Enhance SubscriptionStatusWidget with vehicle usage**
| File | Change |
|------|--------|
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue) | Add vehicle count vs limit display (e.g., "🚗 16/100"), add secondary progress bar for vehicle usage, make plan name more prominent |
| [`frontend/src/components/dashboard/SubscriptionStatusWidget.vue`](frontend/src/components/dashboard/SubscriptionStatusWidget.vue:72-81) | Add `maxVehicles` and `vehicleCount` computed properties (already available from `authStore.user?.max_vehicles` and `vehicleStore.vehicles.length`) |
#### 🟡 P1 — Should Fix
**Task 3: Create Team Members UI page**
| File | Change |
|------|--------|
| `frontend/src/views/organization/TeamMembersView.vue` | **NEW FILE** — Create a full team management page with: member list table, role change dropdown (OWNER/ADMIN/ACCOUNTANT/DRIVER/VIEWER), invitation form, remove member button |
| `frontend/src/router/index.ts` | Add route `/organization/:id/team` pointing to the new view |
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:52-60) | Add "Csapat" menu item to the hamburger menu |
**Task 4: Create Ownership Transfer endpoint + UI**
| File | Change |
|------|--------|
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py) | **NEW ENDPOINT** `POST /{org_id}/transfer-ownership` — requires OWNER role, accepts `new_owner_user_id`, updates `owner_id` on Organization, creates audit log, notifies both parties |
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:486-569) | The existing `PATCH /{org_id}/members/{member_id}` already supports changing role to OWNER, but doesn't update `Organization.owner_id`. Add that logic. |
| Frontend team page | Add "Tulajdonjog átruházása" button (visible only to OWNERs) |
**Task 5: ADMIN subscription management**
| File | Change |
|------|--------|
| [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:775-822) | Add an alternative permission check for `PUT /{org_id}/subscription` that allows org ADMINs (not just system-level) to manage subscriptions. Use `_check_org_admin_access()` instead of `RequireSystemCapability`. |
#### 🟢 P2 — Nice to Fix
**Task 6: Mobile menu alignment verification**
| File | Change |
|------|--------|
| [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:66-75) | Verify the "Költségek" button alignment. If the issue persists, add `text-left` explicitly to the button or wrap the text in a `<span class="text-left">`. |
| [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:40-72) | Same verification for the company hamburger menu |
### Data Flow Diagram (Current vs Proposed)
```
Current:
GET /organizations/my
→ Returns all orgs including individual
→ HeaderCompanySwitcher FILTERS OUT individual
→ User sees only company orgs + hardcoded "Private Garage" button
→ Multiple private garages = invisible
Proposed:
GET /organizations/my
→ Returns all orgs including individual
→ HeaderCompanySwitcher shows ALL orgs grouped:
┌─────────────────────────────┐
│ 👤 Saját garázsok │
│ ✓ Első garázs │
│ Második garázs │
│─────────────────────────────│
│ 🏢 Céges garázsok │
│ Alpha Kft. │
│ Beta Kft. │
└─────────────────────────────┘
```
### Subscription Visibility Enhancement
```
Current SubscriptionStatusWidget:
┌─────────────────────────────┐
│ test_privát 16 nap │
│ ████████░░░░░░ │ ← only time progress
└─────────────────────────────┘
Proposed SubscriptionStatusWidget:
┌─────────────────────────────┐
│ 💎 test_privát Aktív │
│ Lejárat: 2026.07.09 │
│ ████████░░░░░░ 16 nap │ ← time progress
│ 🚗 Járművek: 16/100 │
│ ██████████░░░░ 16% │ ← usage progress
└─────────────────────────────┘
```
---
**End of Audit Report.** Ready for Architect review and approval before implementation.

View File

@@ -0,0 +1,190 @@
# P0 READ & COMPARE — Vendor and Expense Architecture Audit Report
**Date:** 2026-06-23
**Author:** Fast Coder (Core Developer)
**Scope:** Vendor creation flow + Expense creation flow
**Status:** COMPLETE — No code modified, pure audit
---
## 1. AUDIT: Vendor Creation Flow
### 1.1. Endpoint: `POST /providers/quick-add`
**File:** [`backend/app/api/v1/endpoints/providers.py:241`](../backend/app/api/v1/endpoints/providers.py:241)
This is the **primary** vendor creation endpoint used by the frontend's CostEntryWizard when a user adds a new service provider (vendor) via crowdsourcing.
**Actual Code Flow** (in [`backend/app/services/provider_service.py:468`](../backend/app/services/provider_service.py:468)):
| Step | What happens | Line |
|------|-------------|------|
| 1 | Organization created with `org_type=OrgType.service_provider` | `:526` |
| 2 | `owner_id=None` — explicitly set to None | `:541` |
| 3 | `status="pending_verification"`, `is_verified=False` | `:527-528` |
| 4 | ServiceProfile created with `status="ghost"` | `:561-570` |
| 5 | Branch created with address data | `:618-630` |
| 6 | **OrganizationMember created with `role=OrgUserRole.ADMIN`** | `:639-646` |
### 1.2. Comparison: Actual vs Expected
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|--------|--------------------------|-------------|---------|
| `org_type` | `SERVICE_PROVIDER` | `OrgType.service_provider` ✅ | **CORRECT** |
| `owner_id` | `NULL` (ideally) | `owner_id=None` ✅ | **CORRECT** |
| Creator in members? | **NO** — creator should NOT be a member | **BUG:** Creator IS added as `OrganizationMember` with `role=ADMIN` ❌ | **DEVIATION** |
### 1.3. The Member Problem
In [`backend/app/services/provider_service.py:639-646`](../backend/app/services/provider_service.py:639-646):
```python
member = OrganizationMember(
organization_id=org.id,
user_id=user_id,
role=OrgUserRole.ADMIN, # <-- ADMIN, not OWNER
is_permanent=True,
is_verified=True,
)
```
The code comment at line 635 says:
> *"A létrehozó felhasználó ADMIN szerepkörű tag lesz (NEM OWNER). Crowdsourcingból felvett szolgáltatóknak nincs tulajdonosa (owner_id=NULL). Az ADMIN jogosultság elegendő a későbbi szerkesztéshez, de a cég nem jelenik meg a 'Cégeim' garázsválasztóban."*
**However**, the expected business logic says:
> *"A létrehozó felhasználó NEM kerülhet be a tagok (organization_members) közé!"*
This is a **deliberate design decision** — the current code adds the creator as ADMIN. The expected logic says NO membership at all. This needs Architect clarification.
### 1.4. Secondary Endpoint: `POST /organizations/onboard`
**File:** [`backend/app/api/v1/endpoints/organizations.py:38`](../backend/app/api/v1/endpoints/organizations.py:38)
This endpoint is for **business onboarding** (not vendor creation). It creates:
- `org_type=OrgType.business` (line 84)
- `owner_id` is NOT set (implicitly NULL)
- Creator is added as `OrganizationMember` with `role="OWNER"` (line 116-121)
This is **correct** for business onboarding — the creator SHOULD be the owner.
---
## 2. AUDIT: Expense Creation Flow
### 2.1. Endpoint: `POST /expenses/`
**File:** [`backend/app/api/v1/endpoints/expenses.py:345`](../backend/app/api/v1/endpoints/expenses.py:345)
### 2.2. How `organization_id` is Resolved
**Actual Code** (lines 398-429):
```python
# CRITICAL FIX: organization_id MUST be the user's active organization context,
# NOT blindly taken from the payload.
#
# Resolution strategy:
# 1. First, try to resolve the user's active organization from their membership
# 2. Fallback to asset.current_organization_id or asset.owner_org_id
# 3. Ignore expense.organization_id from payload to prevent vendor-org pollution
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none()
if org_member:
organization_id = org_member.organization_id # Priority 1
elif asset.current_organization_id:
organization_id = asset.current_organization_id # Priority 2
elif asset.owner_org_id:
organization_id = asset.owner_org_id # Priority 3
else:
# B2C fallback: any membership (even unverified)
...
```
### 2.3. How `vendor_organization_id` is Set
**Actual Code** (line 495):
```python
vendor_organization_id=expense.vendor_organization_id,
```
The payload's `vendor_organization_id` is passed **directly** to the model. This is the vendor's organization ID.
### 2.4. Comparison: Actual vs Expected
| Aspect | Expected (Business Logic) | Actual Code | Verdict |
|--------|--------------------------|-------------|---------|
| `organization_id` source | Asset's garage (`asset.current_organization_id`) | User's active org membership (Priority 1), then `asset.current_organization_id` (Priority 2) | **PARTIAL DEVIATION** |
| Payload `organization_id` | Should be **IGNORED** | IS ignored — backend resolves from user context ✅ | **CORRECT** |
| `vendor_organization_id` | The invoice issuer (vendor) | Set from `expense.vendor_organization_id` ✅ | **CORRECT** |
### 2.5. The organization_id Deviation Explained
The expected logic says:
> *"A költség organization_id mezőjének (a költség gazdájának) a jármű saját garázsát (asset.current_organization_id) kell tükröznie."*
The actual code uses **Priority 1: user's active org membership**. This means if a user belongs to Organization A (their fleet manager org) but the asset is currently assigned to Organization B (a different garage), the expense will be saved under Organization A, not Organization B.
**This is a potential bug** in multi-org scenarios where a user manages vehicles across multiple organizations. The asset's `current_organization_id` should be the authoritative source.
### 2.6. Frontend Payload Analysis
**File:** [`frontend/src/components/cost/CostEntryWizard.vue:690-723`](../frontend/src/components/cost/CostEntryWizard.vue:690)
The frontend:
1. Does **NOT** send `vendor_organization_id` in the payload (line 690-714)
2. Sends `organization_id` = user's `active_organization_id` (line 720-723)
3. Stores vendor info inside `data.vendor_id` and `data.vendor_name` (JSONB) (line 707-710)
**File:** [`frontend/src/stores/cost.ts:64-73`](../frontend/src/stores/cost.ts:64)
The cost store also injects `organization_id` from `authStore.user?.active_organization_id`.
**This means:** The frontend never sends `vendor_organization_id` to the backend! The vendor info is stored in the JSONB `data` field, but the dedicated `vendor_organization_id` column on `AssetCost` remains NULL.
---
## 3. Summary of Findings
### 🟢 CORRECT (Matches Expected Logic)
| # | Finding | Location |
|---|---------|----------|
| 1 | `POST /providers/quick-add` creates org with `org_type=service_provider` | [`provider_service.py:526`](../backend/app/services/provider_service.py:526) |
| 2 | `owner_id=None` for crowdsourced providers | [`provider_service.py:541`](../backend/app/services/provider_service.py:541) |
| 3 | Backend `POST /expenses/` ignores payload `organization_id` | [`expenses.py:398-429`](../backend/app/api/v1/endpoints/expenses.py:398) |
| 4 | `vendor_organization_id` is correctly mapped from schema | [`expenses.py:495`](../backend/app/api/v1/endpoints/expenses.py:495) |
### 🟡 DEVIATION (Needs Architect Decision)
| # | Finding | Location | Impact |
|---|---------|----------|--------|
| 1 | **Vendor creator IS added as OrganizationMember (ADMIN)** — expected: NO membership | [`provider_service.py:639-646`](../backend/app/services/provider_service.py:639) | Vendor appears in user's org list? |
| 2 | **`organization_id` resolved from user's active org (Priority 1)** — expected: `asset.current_organization_id` | [`expenses.py:406-414`](../backend/app/api/v1/endpoints/expenses.py:406) | Wrong org in multi-org scenarios |
| 3 | **Frontend never sends `vendor_organization_id`** — vendor info only in JSONB `data` | [`CostEntryWizard.vue:690-714`](../frontend/src/components/cost/CostEntryWizard.vue:690) | `vendor_organization_id` column always NULL |
### 🔴 CRITICAL GAP
| # | Finding | Location |
|---|---------|----------|
| 1 | Frontend `CostEntryWizard` does NOT populate `vendor_organization_id` in the POST payload | [`CostEntryWizard.vue:707`](../frontend/src/components/cost/CostEntryWizard.vue:707) — vendor_id stored only in `data.vendor_id` |
| 2 | The `cost.ts` store also doesn't map `vendor_organization_id` | [`cost.ts:53-73`](../frontend/src/stores/cost.ts:53) |
---
## 4. Recommended Actions
1. **Architect Decision Needed:** Should `quick_add_provider` add the creator as `OrganizationMember` (current behavior) or NOT (expected logic)? The current ADMIN role prevents the org from appearing in "My Companies" garage selector, but the user still has a membership record.
2. **Fix `organization_id` Resolution:** Change Priority 1 from "user's active org membership" to `asset.current_organization_id` to match the expected business logic exactly.
3. **Fix Frontend:** Add `vendor_organization_id` to the POST `/expenses/` payload when a vendor is selected from the dropdown, so the dedicated column is populated instead of only storing it in JSONB `data`.
---
*End of Audit Report — No code was modified during this analysis.*