From 7168fa4b720ea9745149b2ab2c262b274754efaa Mon Sep 17 00:00:00 2001 From: Stefano Manfredi <56640837+stemanfredi@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:30:04 +0000 Subject: [PATCH] Refactor to manager-centric model, add team calendar for all users Key changes: - Removed office-centric model (deleted offices.py, office-rules) - Renamed to team-rules, managers are part of their own team - Team calendar visible to all (read-only for employees) - Admins can have a manager assigned --- CLAUDE.md | 263 ++++++++++++++++++ README.md | 13 +- app/config.py | 4 +- app/routes/auth.py | 8 +- app/routes/managers.py | 37 +-- app/routes/offices.py | 197 ------------- app/routes/parking.py | 20 +- app/routes/presence.py | 113 +++++--- app/routes/users.py | 180 +++++++----- compose.yml | 13 +- create_test_db.py | 107 ++----- database/connection.py | 3 + database/models.py | 48 +--- deploy/DEPLOY.md | 190 +++++++++---- frontend/js/admin-users.js | 235 +++++++--------- frontend/js/api.js | 37 ++- frontend/js/nav.js | 25 +- frontend/js/presence.js | 4 +- frontend/js/team-calendar.js | 54 ++-- .../js/{office-rules.js => team-rules.js} | 58 ++-- frontend/pages/admin-users.html | 52 ++-- frontend/pages/landing.html | 23 +- frontend/pages/profile.html | 78 ++++-- frontend/pages/register.html | 30 +- .../{office-rules.html => team-rules.html} | 16 +- main.py | 10 +- services/auth.py | 4 +- services/notifications.py | 2 +- services/parking.py | 57 ++-- utils/auth_middleware.py | 45 +-- 30 files changed, 1016 insertions(+), 910 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 app/routes/offices.py rename frontend/js/{office-rules.js => team-rules.js} (83%) rename frontend/pages/{office-rules.html => team-rules.html} (94%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e838e0a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,263 @@ +# CLAUDE.md - Project Intelligence + +## Project Overview + +**Org-Parking** is a manager-centric parking spot management system for organizations. It features fair parking assignment based on presence/parking ratio, supporting both standalone JWT authentication and Authelia/LLDAP SSO integration. + +### Technology Stack +- **Backend:** FastAPI + SQLAlchemy + SQLite +- **Frontend:** Vanilla JavaScript (no frameworks) +- **Auth:** JWT tokens + Authelia SSO support +- **Containerization:** Docker + Docker Compose + +### Architecture +``` +app/routes/ → API endpoints (auth, users, managers, presence, parking) +services/ → Business logic (parking algorithm, auth, notifications) +database/ → SQLAlchemy models and connection +frontend/ → Static HTML pages + JS modules +utils/ → Auth middleware +``` + +## Build & Run Commands + +```bash +# Development +python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000 + +# Docker +docker compose up -d + +# Dependencies +pip install -r requirements.txt + +# Initialize test database +python create_test_db.py +``` + +## Code Style & Conventions + +### Python +- FastAPI async patterns with `Depends()` for dependency injection +- Pydantic models for request/response validation +- SQLAlchemy ORM (no raw SQL) +- UUIDs as string primary keys: `str(uuid.uuid4())` +- Dates stored as TEXT in "YYYY-MM-DD" format + +### JavaScript +- ES6 modules with centralized API client (`/js/api.js`) +- Token stored in localStorage, auto-included in requests +- Utility functions in `/js/utils.js` +- Role-based navigation in `/js/nav.js` + +### Authentication +- Dual mode: JWT tokens (standalone) or Authelia headers (SSO) +- LDAP users have `password_hash = None` +- Check pattern: `config.AUTHELIA_ENABLED and user.password_hash is None` + +--- + +## Known Issues & Technical Debt + +### Critical + +1. **Inactive Notification System** + - Location: [services/notifications.py](services/notifications.py) + - Issue: All code implemented but no scheduler integrated + - TODO: Integrate APScheduler or similar + +2. **Default SECRET_KEY** + - Location: [app/config.py:13](app/config.py#L13) + - Issue: Defaults to "change-me-in-production" + - Fix: Add startup validation to error if default key used + +3. **No CSRF Protection** + - Forms use token auth only, vulnerable to CSRF attacks + - Fix: Implement CSRF tokens or validate referer header + +4. **No Rate Limiting** + - Login endpoint has no brute force protection + - Fix: Add slowapi or similar middleware + +### Performance + +1. **N+1 Query Problems** + - Location: [app/routes/managers.py:244-259](app/routes/managers.py#L244-L259) + - Location: [app/routes/presence.py:336-419](app/routes/presence.py#L336-L419) + - Issue: Loops that query database for each item + - Fix: Use joins and relationship loading + +2. **Inefficient Spot Prefix Lookups** + - Location: [services/parking.py:56-64](services/parking.py#L56-L64) + - Issue: Repeated DB queries for same data + - Fix: Cache in request context + +### Code Quality + +1. **Duplicated LDAP Check Logic** (4+ locations) + ```python + # Appears in: users.py:91, 168, 257, 280 + is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None + ``` + - Fix: Create `utils.is_ldap_user(user)` helper + +2. **Inline JavaScript in HTML** + - 500+ lines embedded across pages + - Affected: team-rules.html, team-calendar.html, settings.html + - Fix: Extract to separate JS files + +3. **Inconsistent Response Formats** + - Some endpoints return dicts, others Pydantic models + - Fix: Standardize on Pydantic response schemas + +4. **God Object: User Model** + - Location: [database/models.py:11-47](database/models.py#L11-L47) + - Issue: 27 columns mixing auth, profile, preferences, manager settings + - Fix: Normalize into UserProfile, UserPreferences, ManagerSettings tables + +5. **Repetitive CRUD in managers.py** + - Location: [app/routes/managers.py](app/routes/managers.py) + - Issue: 4 resources × 3 operations with near-identical code + - Fix: Create generic CRUD factory or base class + +6. **Silent Exception Handling** + - Location: [app/routes/presence.py:135-143](app/routes/presence.py#L135-L143) + - Issue: Catches all exceptions and only prints + - Fix: Log properly and propagate meaningful errors + +--- + +## Areas for Simplification + +### 1. Consolidate Response Building +The `user_to_response()` pattern is duplicated in [users.py:76-107](app/routes/users.py#L76-L107) and [users.py:254-274](app/routes/users.py#L254-L274). Create single reusable function. + +### 2. Generic CRUD Router Factory +[managers.py](app/routes/managers.py) has 12 nearly identical endpoints. Create: +```python +def create_crud_router(model: Type, schema: Type, parent_key: str): + router = APIRouter() + # Generate GET, POST, DELETE endpoints + return router +``` + +### 3. Frontend State Management +Each page maintains its own globals (`currentUser`, `currentData`). Consider simple app-level state or Web Components. + +### 4. Date Handling +All dates stored as TEXT strings. Could use proper DATE columns for better query performance and validation. + +### 5. Notification Service Activation +Create [services/scheduler.py](services/scheduler.py) with APScheduler: +```python +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler = AsyncIOScheduler() +scheduler.add_job(process_notification_queue, 'interval', minutes=5) +``` + +--- + +## Security Improvements Needed + +| Priority | Issue | Location | Recommendation | +|----------|-------|----------|----------------| +| HIGH | Rate limiting | main.py | Add slowapi middleware | +| HIGH | CSRF protection | All forms | Implement CSRF tokens | +| HIGH | Secret validation | config.py:13 | Error on default key | +| MEDIUM | Password validation | auth.py:63-67 | Enforce complexity rules | +| MEDIUM | Input sanitization | notification emails | Use template library | +| LOW | CORS configuration | compose.yml | Document production settings | + +--- + +## Testing Strategy (Missing) + +No tests currently exist. Recommended structure: +``` +tests/ +├── conftest.py # Fixtures (test DB, client, auth) +├── test_auth.py # JWT and Authelia modes +├── test_parking.py # Fair assignment algorithm +├── test_presence.py # Bulk operations, team calendar +├── test_managers.py # CRUD operations +└── integration/ + └── test_workflows.py # End-to-end scenarios +``` + +Key test scenarios: +1. Parking algorithm with varying ratios +2. Manager closing days affect assignments +3. Guarantee and exclusion rules +4. Authelia header authentication flow +5. LDAP vs local user password handling + +--- + +## API Quick Reference + +### Authentication +- `POST /api/auth/register` - Create user (standalone mode) +- `POST /api/auth/login` - Get JWT token +- `GET /api/auth/me` - Current user (JWT or Authelia) + +### Presence +- `POST /api/presence/mark` - Mark single day +- `POST /api/presence/mark-bulk` - Mark multiple days +- `GET /api/presence/team` - Team calendar with parking + +### Parking +- `POST /api/parking/manual-assign` - Manager assigns spot +- `POST /api/parking/reassign-spot` - Reassign existing spot +- `GET /api/parking/eligible-users/{id}` - Users for reassignment + +### Manager Settings +- `GET/POST/DELETE /api/managers/closing-days` +- `GET/POST/DELETE /api/managers/weekly-closing-days` +- `GET/POST/DELETE /api/managers/guarantees` +- `GET/POST/DELETE /api/managers/exclusions` + +--- + +## Development Notes + +### Adding a New Route +1. Create file in `app/routes/` +2. Use `APIRouter(prefix="/api/...", tags=["..."])` +3. Register in `main.py`: `app.include_router(...)` +4. Add auth dependency: `current_user: User = Depends(get_current_user)` + +### Database Migrations +No migration system (Alembic) configured. Schema changes require: +1. Update [database/models.py](database/models.py) +2. Delete SQLite file or write manual migration +3. Run `create_test_db.py` for fresh database + +### Frontend Page Pattern +```html + +``` + +--- + +## File Quick Links + +| Purpose | File | +|---------|------| +| Main entry | [main.py](main.py) | +| Configuration | [app/config.py](app/config.py) | +| Database models | [database/models.py](database/models.py) | +| Parking algorithm | [services/parking.py](services/parking.py) | +| Auth middleware | [utils/auth_middleware.py](utils/auth_middleware.py) | +| Frontend API client | [frontend/js/api.js](frontend/js/api.js) | +| CSS styles | [frontend/css/styles.css](frontend/css/styles.css) | +| Docker config | [compose.yml](compose.yml) | diff --git a/README.md b/README.md index 827d98e..a7492e5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ A manager-centric parking spot management application with fair assignment algor │ ├── routes/ # API endpoints │ │ ├── auth.py # Authentication + holidays │ │ ├── users.py # User management -│ │ ├── offices.py # Office CRUD │ │ ├── managers.py # Manager rules (closing days, guarantees) │ │ ├── presence.py # Presence marking │ │ └── parking.py # Parking assignments @@ -116,8 +115,8 @@ Group mapping (follows lldap naming convention): | Role | Permissions | |------|-------------| -| **admin** | Full access, manage users/offices | -| **manager** | Manage assigned offices, set rules | +| **admin** | Full access, manage users and managers | +| **manager** | Manage their team, set parking rules | | **employee** | Mark own presence, view calendar | ## API Endpoints @@ -137,12 +136,6 @@ Group mapping (follows lldap naming convention): - `GET /api/users/me/profile` - Own profile - `PUT /api/users/me/settings` - Own settings -### Offices -- `GET /api/offices` - List offices -- `POST /api/offices` - Create office (admin) -- `PUT /api/offices/{id}` - Update office (admin) -- `DELETE /api/offices/{id}` - Delete office (admin) - ### Managers - `GET /api/managers` - List managers - `GET /api/managers/{id}` - Manager details @@ -169,7 +162,7 @@ Group mapping (follows lldap naming convention): Parking spots are assigned based on a fairness ratio: ``` -ratio = parking_days / office_days +ratio = parking_days / presence_days ``` Users with the lowest ratio get priority. Guaranteed users are always assigned first. diff --git a/app/config.py b/app/config.py index 3049206..02c3e65 100644 --- a/app/config.py +++ b/app/config.py @@ -28,9 +28,9 @@ AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User") AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name") AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email") AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups") -# Group to role mapping (follows lldap naming convention) +# Only parking_admins group is synced from LLDAP -> admin role +# Manager role and user assignments are managed by admin in the app UI AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking_admins") -AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "managers") # Email (optional) SMTP_HOST = os.getenv("SMTP_HOST", "") diff --git a/app/routes/auth.py b/app/routes/auth.py index 0e1d8e9..dded0e7 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -22,7 +22,7 @@ class RegisterRequest(BaseModel): email: EmailStr password: str name: str - office_id: str | None = None + manager_id: str | None = None class LoginRequest(BaseModel): @@ -39,7 +39,7 @@ class UserResponse(BaseModel): id: str email: str name: str | None - office_id: str | None + manager_id: str | None role: str manager_parking_quota: int | None = None week_start_day: int = 0 @@ -71,7 +71,7 @@ def register(data: RegisterRequest, db: Session = Depends(get_db)): email=data.email, password=data.password, name=data.name, - office_id=data.office_id + manager_id=data.manager_id ) token = create_access_token(user.id, user.email) @@ -116,7 +116,7 @@ def get_me(user=Depends(get_current_user)): id=user.id, email=user.email, name=user.name, - office_id=user.office_id, + manager_id=user.manager_id, role=user.role, manager_parking_quota=user.manager_parking_quota, week_start_day=user.week_start_day or 0, diff --git a/app/routes/managers.py b/app/routes/managers.py index c3c379f..380b13a 100644 --- a/app/routes/managers.py +++ b/app/routes/managers.py @@ -2,8 +2,8 @@ Manager Rules Routes Manager settings, closing days, guarantees, and exclusions -Key concept: Managers own parking spots and set rules for all their managed offices. -Rules are set at manager level, not office level. +Key concept: Managers own parking spots and set rules for their managed users. +Rules are set at manager level (users have manager_id pointing to their manager). """ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException @@ -13,7 +13,7 @@ import uuid from database.connection import get_db from database.models import ( - Office, User, OfficeMembership, + User, ManagerClosingDay, ManagerWeeklyClosingDay, ParkingGuarantee, ParkingExclusion ) @@ -52,14 +52,12 @@ class ManagerSettingsUpdate(BaseModel): # Manager listing and details @router.get("") def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): - """Get all managers with their managed offices and parking quota""" + """Get all managers with their managed user count and parking quota""" managers = db.query(User).filter(User.role == "manager").all() result = [] for manager in managers: - memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all() - office_ids = [m.office_id for m in memberships] - offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + managed_user_count = db.query(User).filter(User.manager_id == manager.id).count() result.append({ "id": manager.id, @@ -67,7 +65,7 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or "email": manager.email, "parking_quota": manager.manager_parking_quota or 0, "spot_prefix": manager.manager_spot_prefix, - "offices": [{"id": o.id, "name": o.name} for o in offices] + "managed_user_count": managed_user_count }) return result @@ -75,14 +73,12 @@ def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or @router.get("/{manager_id}") def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): - """Get manager details including offices and parking settings""" + """Get manager details including managed users and parking settings""" manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() if not manager: raise HTTPException(status_code=404, detail="Manager not found") - memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() - office_ids = [m.office_id for m in memberships] - offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] + managed_user_count = db.query(User).filter(User.manager_id == manager_id).count() return { "id": manager.id, @@ -90,7 +86,7 @@ def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Dep "email": manager.email, "parking_quota": manager.manager_parking_quota or 0, "spot_prefix": manager.manager_spot_prefix, - "offices": [{"id": o.id, "name": o.name} for o in offices] + "managed_user_count": managed_user_count } @@ -131,19 +127,16 @@ def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Se @router.get("/{manager_id}/users") def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): - """Get all users from offices managed by this manager""" + """Get all users in a manager's team (including the manager themselves)""" manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first() if not manager: raise HTTPException(status_code=404, detail="Manager not found") - memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all() - managed_office_ids = [m.office_id for m in memberships] - - if not managed_office_ids: - return [] - - users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all() - return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users] + # Include users managed by this manager + the manager themselves + users = db.query(User).filter( + (User.manager_id == manager_id) | (User.id == manager_id) + ).all() + return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role} for u in users] # Closing days diff --git a/app/routes/offices.py b/app/routes/offices.py deleted file mode 100644 index 420ff01..0000000 --- a/app/routes/offices.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -Office Management Routes -Admin CRUD for offices and manager-office memberships -""" -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from sqlalchemy.orm import Session -import uuid - -from database.connection import get_db -from database.models import Office, User, OfficeMembership -from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user - -router = APIRouter(prefix="/api/offices", tags=["offices"]) - - -# Request/Response Models -class OfficeCreate(BaseModel): - name: str - location: str | None = None - - -class OfficeUpdate(BaseModel): - name: str | None = None - location: str | None = None - - -class OfficeResponse(BaseModel): - id: str - name: str - location: str | None = None - created_at: str | None - - class Config: - from_attributes = True - - -class AddManagerRequest(BaseModel): - user_id: str - - -# Office CRUD Routes -@router.get("") -def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)): - """List all offices with counts""" - offices = db.query(Office).all() - result = [] - for office in offices: - manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count() - employee_count = db.query(User).filter(User.office_id == office.id).count() - result.append({ - "id": office.id, - "name": office.name, - "location": office.location, - "created_at": office.created_at, - "manager_count": manager_count, - "employee_count": employee_count - }) - return result - - -@router.get("/{office_id}", response_model=OfficeResponse) -def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): - """Get office by ID""" - office = db.query(Office).filter(Office.id == office_id).first() - if not office: - raise HTTPException(status_code=404, detail="Office not found") - return office - - -@router.post("", response_model=OfficeResponse) -def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)): - """Create new office (admin only)""" - office = Office( - id=str(uuid.uuid4()), - name=data.name, - location=data.location, - created_at=datetime.utcnow().isoformat() - ) - - db.add(office) - db.commit() - db.refresh(office) - return office - - -@router.put("/{office_id}", response_model=OfficeResponse) -def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): - """Update office (admin only)""" - office = db.query(Office).filter(Office.id == office_id).first() - if not office: - raise HTTPException(status_code=404, detail="Office not found") - - if data.name is not None: - office.name = data.name - if data.location is not None: - office.location = data.location - - office.updated_at = datetime.utcnow().isoformat() - db.commit() - db.refresh(office) - return office - - -@router.delete("/{office_id}") -def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): - """Delete office (admin only)""" - office = db.query(Office).filter(Office.id == office_id).first() - if not office: - raise HTTPException(status_code=404, detail="Office not found") - - if db.query(User).filter(User.office_id == office_id).count() > 0: - raise HTTPException(status_code=400, detail="Cannot delete office with assigned users") - - db.delete(office) - db.commit() - return {"message": "Office deleted"} - - -# Office membership routes (linking managers to offices) -@router.get("/{office_id}/managers") -def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): - """Get managers for an office""" - memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all() - manager_ids = [m.user_id for m in memberships] - managers = db.query(User).filter(User.id.in_(manager_ids)).all() - return [{"id": m.id, "name": m.name, "email": m.email} for m in managers] - - -@router.post("/{office_id}/managers") -def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)): - """Add manager to office (admin only)""" - if not db.query(Office).filter(Office.id == office_id).first(): - raise HTTPException(status_code=404, detail="Office not found") - - manager = db.query(User).filter(User.id == data.user_id).first() - if not manager: - raise HTTPException(status_code=404, detail="User not found") - if manager.role != "manager": - raise HTTPException(status_code=400, detail="User must have manager role") - - existing = db.query(OfficeMembership).filter( - OfficeMembership.office_id == office_id, - OfficeMembership.user_id == data.user_id - ).first() - if existing: - raise HTTPException(status_code=400, detail="Manager already assigned to office") - - membership = OfficeMembership( - id=str(uuid.uuid4()), - office_id=office_id, - user_id=data.user_id, - created_at=datetime.utcnow().isoformat() - ) - db.add(membership) - db.commit() - return {"message": "Manager added to office"} - - -@router.delete("/{office_id}/managers/{manager_id}") -def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): - """Remove manager from office (admin only)""" - membership = db.query(OfficeMembership).filter( - OfficeMembership.office_id == office_id, - OfficeMembership.user_id == manager_id - ).first() - if not membership: - raise HTTPException(status_code=404, detail="Manager not assigned to office") - - db.delete(membership) - db.commit() - return {"message": "Manager removed from office"} - - -# Legacy redirect for /api/offices/managers/list -> /api/managers -@router.get("/managers/list") -def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)): - """Get all managers with their managed offices and parking quota (legacy endpoint)""" - managers = db.query(User).filter(User.role == "manager").all() - result = [] - - for manager in managers: - memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all() - office_ids = [m.office_id for m in memberships] - offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else [] - - result.append({ - "id": manager.id, - "name": manager.name, - "email": manager.email, - "parking_quota": manager.manager_parking_quota or 0, - "spot_prefix": manager.manager_spot_prefix, - "offices": [{"id": o.id, "name": o.name} for o in offices] - }) - - return result diff --git a/app/routes/parking.py b/app/routes/parking.py index 728907f..e65b138 100644 --- a/app/routes/parking.py +++ b/app/routes/parking.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session import uuid from database.connection import get_db -from database.models import DailyParkingAssignment, User, OfficeMembership +from database.models import DailyParkingAssignment, User from utils.auth_middleware import get_current_user, require_manager_or_admin from services.parking import initialize_parking_pool, get_spot_display_name from services.notifications import queue_parking_change_notification @@ -49,7 +49,6 @@ class AssignmentResponse(BaseModel): manager_id: str user_name: str | None = None user_email: str | None = None - user_office_id: str | None = None # Routes @@ -102,7 +101,6 @@ def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get if user: result.user_name = user.name result.user_email = user.email - result.user_office_id = user.office_id results.append(result) @@ -307,7 +305,7 @@ def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), curr @router.get("/eligible-users/{assignment_id}") def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get users eligible for reassignment of a parking spot. - Returns users in the same manager's offices. + Returns users managed by the same manager. """ assignment = db.query(DailyParkingAssignment).filter( DailyParkingAssignment.id == assignment_id @@ -324,16 +322,9 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren if not (is_admin or is_manager or is_spot_owner): raise HTTPException(status_code=403, detail="Not authorized") - # Get all users belonging to offices managed by this manager - # Get offices managed by this manager - managed_office_ids = db.query(OfficeMembership.office_id).filter( - OfficeMembership.user_id == assignment.manager_id - ).all() - managed_office_ids = [o[0] for o in managed_office_ids] - - # Get users in those offices + # Get users in this manager's team (including the manager themselves) users = db.query(User).filter( - User.office_id.in_(managed_office_ids), + (User.manager_id == assignment.manager_id) | (User.id == assignment.manager_id), User.id != assignment.user_id # Exclude current holder ).all() @@ -351,8 +342,7 @@ def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), curren result.append({ "id": user.id, "name": user.name, - "email": user.email, - "office_id": user.office_id + "email": user.email }) return result diff --git a/app/routes/presence.py b/app/routes/presence.py index 9116354..a6a2aca 100644 --- a/app/routes/presence.py +++ b/app/routes/presence.py @@ -10,8 +10,8 @@ from sqlalchemy.orm import Session import uuid from database.connection import get_db -from database.models import UserPresence, User, DailyParkingAssignment, OfficeMembership, Office -from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user +from database.models import UserPresence, User, DailyParkingAssignment +from utils.auth_middleware import get_current_user, require_manager_or_admin from services.parking import handle_presence_change, get_spot_display_name router = APIRouter(prefix="/api/presence", tags=["presence"]) @@ -70,6 +70,20 @@ def parse_date(date_str: str) -> datetime: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") +def check_manager_access(current_user: User, target_user: User, db: Session): + """Check if current_user has access to target_user""" + if current_user.role == "admin": + return True + + if current_user.role == "manager": + # Manager can access users they manage + if target_user.manager_id == current_user.id: + return True + raise HTTPException(status_code=403, detail="User is not managed by you") + + raise HTTPException(status_code=403, detail="Access denied") + + def _mark_presence_for_user( user_id: str, date: str, @@ -111,12 +125,18 @@ def _mark_presence_for_user( db.refresh(presence) # Handle parking assignment - if old_status != status and target_user.office_id: + # Use manager_id if user has one, or user's own id if they are a manager + parking_manager_id = target_user.manager_id + if not parking_manager_id and target_user.role == "manager": + # Manager is part of their own team for parking purposes + parking_manager_id = target_user.id + + if old_status != status and parking_manager_id: try: handle_presence_change( user_id, date, old_status or "absent", status, - target_user.office_id, db + parking_manager_id, db ) except Exception as e: print(f"Warning: Parking handler failed: {e}") @@ -177,12 +197,17 @@ def _bulk_mark_presence( results.append(presence) # Handle parking for each date - if old_status != status and target_user.office_id: + # Use manager_id if user has one, or user's own id if they are a manager + parking_manager_id = target_user.manager_id + if not parking_manager_id and target_user.role == "manager": + parking_manager_id = target_user.id + + if old_status != status and parking_manager_id: try: handle_presence_change( user_id, date_str, old_status or "absent", status, - target_user.office_id, db + parking_manager_id, db ) except Exception: pass @@ -216,12 +241,17 @@ def _delete_presence( db.delete(presence) db.commit() - if target_user.office_id: + # Use manager_id if user has one, or user's own id if they are a manager + parking_manager_id = target_user.manager_id + if not parking_manager_id and target_user.role == "manager": + parking_manager_id = target_user.id + + if parking_manager_id: try: handle_presence_change( user_id, date, old_status, "absent", - target_user.office_id, db + parking_manager_id, db ) except Exception: pass @@ -274,7 +304,7 @@ def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(ge if not target_user: raise HTTPException(status_code=404, detail="User not found") - check_manager_access_to_user(current_user, target_user, db) + check_manager_access(current_user, target_user, db) return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user) @@ -285,7 +315,7 @@ def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depen if not target_user: raise HTTPException(status_code=404, detail="User not found") - check_manager_access_to_user(current_user, target_user, db) + check_manager_access(current_user, target_user, db) return _bulk_mark_presence( data.user_id, data.start_date, data.end_date, data.status, data.days, db, target_user @@ -299,34 +329,42 @@ def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db) if not target_user: raise HTTPException(status_code=404, detail="User not found") - check_manager_access_to_user(current_user, target_user, db) + check_manager_access(current_user, target_user, db) return _delete_presence(user_id, date, db, target_user) @router.get("/team") -def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)): - """Get team presences with parking info for managers/admins, filtered by manager""" +def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + """Get team presences with parking info, filtered by manager. + - Admins can see all teams + - Managers see their own team + - Employees can only see their own team (read-only view) + """ parse_date(start_date) parse_date(end_date) # Get users based on permissions and manager filter - if manager_id: - # Filter by specific manager's offices - managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter( - OfficeMembership.user_id == manager_id - ).all()] - if not managed_office_ids: - return [] - users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all() + # Note: Manager is part of their own team (for parking assignment purposes) + if current_user.role == "employee": + # Employees can only see their own team (users with same manager_id + the manager) + if not current_user.manager_id: + return [] # No manager assigned, no team to show + users = db.query(User).filter( + (User.manager_id == current_user.manager_id) | (User.id == current_user.manager_id) + ).all() + elif manager_id: + # Filter by specific manager (for admins/managers) - include the manager themselves + users = db.query(User).filter( + (User.manager_id == manager_id) | (User.id == manager_id) + ).all() elif current_user.role == "admin": # Admin sees all users users = db.query(User).all() else: - # Manager sees only users in their managed offices - managed_ids = [m.office_id for m in current_user.managed_offices] - if not managed_ids: - return [] - users = db.query(User).filter(User.office_id.in_(managed_ids)).all() + # Manager sees their team + themselves + users = db.query(User).filter( + (User.manager_id == current_user.id) | (User.id == current_user.id) + ).all() # Batch query presences and parking for all users user_ids = [u.id for u in users] @@ -358,30 +396,21 @@ def get_team_presences(start_date: str, end_date: str, manager_id: str = None, d "spot_display_name": spot_display_name }) - # Build office and managed offices lookups - offices_lookup = {o.id: o.name for o in db.query(Office).all()} - managed_offices_lookup = {} - for m in db.query(OfficeMembership).all(): - if m.user_id not in managed_offices_lookup: - managed_offices_lookup[m.user_id] = [] - managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id)) + # Build manager lookup for display + manager_ids = list(set(u.manager_id for u in users if u.manager_id)) + managers = db.query(User).filter(User.id.in_(manager_ids)).all() if manager_ids else [] + manager_lookup = {m.id: m.name for m in managers} # Build response result = [] for user in users: user_presences = [p for p in presences if p.user_id == user.id] - # For managers, show managed offices; for others, show their office - if user.role == "manager" and user.id in managed_offices_lookup: - office_display = ", ".join(managed_offices_lookup[user.id]) - else: - office_display = offices_lookup.get(user.office_id) - result.append({ "id": user.id, "name": user.name, - "office_id": user.office_id, - "office_name": office_display, + "manager_id": user.manager_id, + "manager_name": manager_lookup.get(user.manager_id), "presences": [{"date": p.date, "status": p.status} for p in user_presences], "parking_dates": parking_lookup.get(user.id, []), "parking_info": parking_info_lookup.get(user.id, []) @@ -397,7 +426,7 @@ def get_user_presences(user_id: str, start_date: str = None, end_date: str = Non if not target_user: raise HTTPException(status_code=404, detail="User not found") - check_manager_access_to_user(current_user, target_user, db) + check_manager_access(current_user, target_user, db) query = db.query(UserPresence).filter(UserPresence.user_id == user_id) diff --git a/app/routes/users.py b/app/routes/users.py index d9bef3d..2548589 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -11,9 +11,10 @@ import uuid import re from database.connection import get_db -from database.models import User, Office, OfficeMembership +from database.models import User from utils.auth_middleware import get_current_user, require_admin from services.auth import hash_password, verify_password +from app import config router = APIRouter(prefix="/api/users", tags=["users"]) @@ -24,21 +25,19 @@ class UserCreate(BaseModel): password: str name: str | None = None role: str = "employee" - office_id: str | None = None + manager_id: str | None = None class UserUpdate(BaseModel): - email: EmailStr | None = None name: str | None = None role: str | None = None - office_id: str | None = None + manager_id: str | None = None manager_parking_quota: int | None = None manager_spot_prefix: str | None = None class ProfileUpdate(BaseModel): name: str | None = None - office_id: str | None = None class SettingsUpdate(BaseModel): @@ -61,44 +60,86 @@ class UserResponse(BaseModel): email: str name: str | None role: str - office_id: str | None + manager_id: str | None = None + manager_name: str | None = None manager_parking_quota: int | None = None manager_spot_prefix: str | None = None + managed_user_count: int | None = None + is_ldap_user: bool = False + is_ldap_admin: bool = False created_at: str | None class Config: from_attributes = True +def user_to_response(user: User, db: Session) -> dict: + """Convert user to response dict with computed fields""" + # Get manager name if user has a manager + manager_name = None + if user.manager_id: + manager = db.query(User).filter(User.id == user.manager_id).first() + if manager: + manager_name = manager.name + + # Count managed users if this user is a manager + managed_user_count = None + if user.role == "manager": + managed_user_count = db.query(User).filter(User.manager_id == user.id).count() + + # Determine if user is LDAP-managed + is_ldap_user = config.AUTHELIA_ENABLED and user.password_hash is None + is_ldap_admin = is_ldap_user and user.role == "admin" + + return { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "manager_id": user.manager_id, + "manager_name": manager_name, + "manager_parking_quota": user.manager_parking_quota, + "manager_spot_prefix": user.manager_spot_prefix, + "managed_user_count": managed_user_count, + "is_ldap_user": is_ldap_user, + "is_ldap_admin": is_ldap_admin, + "created_at": user.created_at + } + + # Admin Routes -@router.get("", response_model=List[UserResponse]) +@router.get("") def list_users(db: Session = Depends(get_db), user=Depends(require_admin)): """List all users (admin only)""" users = db.query(User).all() - return users + return [user_to_response(u, db) for u in users] -@router.get("/{user_id}", response_model=UserResponse) +@router.get("/{user_id}") def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)): """Get user by ID (admin only)""" target = db.query(User).filter(User.id == user_id).first() if not target: raise HTTPException(status_code=404, detail="User not found") - return target + return user_to_response(target, db) -@router.post("", response_model=UserResponse) +@router.post("") def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)): - """Create new user (admin only)""" + """Create new user (admin only) - only for non-LDAP mode""" + if config.AUTHELIA_ENABLED: + raise HTTPException(status_code=400, detail="User creation disabled in LDAP mode. Users are created on first login.") + if db.query(User).filter(User.email == data.email).first(): raise HTTPException(status_code=400, detail="Email already registered") if data.role not in ["admin", "manager", "employee"]: raise HTTPException(status_code=400, detail="Invalid role") - if data.office_id: - if not db.query(Office).filter(Office.id == data.office_id).first(): - raise HTTPException(status_code=404, detail="Office not found") + if data.manager_id: + manager = db.query(User).filter(User.id == data.manager_id).first() + if not manager or manager.role != "manager": + raise HTTPException(status_code=400, detail="Invalid manager") new_user = User( id=str(uuid.uuid4()), @@ -106,42 +147,61 @@ def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(re password_hash=hash_password(data.password), name=data.name, role=data.role, - office_id=data.office_id, + manager_id=data.manager_id, created_at=datetime.utcnow().isoformat() ) db.add(new_user) db.commit() db.refresh(new_user) - return new_user + return user_to_response(new_user, db) -@router.put("/{user_id}", response_model=UserResponse) +@router.put("/{user_id}") def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)): """Update user (admin only)""" target = db.query(User).filter(User.id == user_id).first() if not target: raise HTTPException(status_code=404, detail="User not found") - if data.email is not None: - existing = db.query(User).filter(User.email == data.email, User.id != user_id).first() - if existing: - raise HTTPException(status_code=400, detail="Email already in use") - target.email = data.email + # Check if user is LDAP-managed + is_ldap_user = config.AUTHELIA_ENABLED and target.password_hash is None + is_ldap_admin = is_ldap_user and target.role == "admin" + # Name update - blocked for LDAP users if data.name is not None: + if is_ldap_user: + raise HTTPException(status_code=400, detail="Name is managed by LDAP") target.name = data.name + # Role update if data.role is not None: if data.role not in ["admin", "manager", "employee"]: raise HTTPException(status_code=400, detail="Invalid role") + # Can't change admin role for LDAP admins (they get admin from parking_admins group) + if is_ldap_admin and data.role != "admin": + raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)") + # If changing from manager to another role, check for managed users + if target.role == "manager" and data.role != "manager": + managed_count = db.query(User).filter(User.manager_id == user_id).count() + if managed_count > 0: + raise HTTPException(status_code=400, detail=f"Cannot change role: {managed_count} users are assigned to this manager") + # Clear manager-specific fields + target.manager_parking_quota = 0 + target.manager_spot_prefix = None target.role = data.role - if data.office_id is not None: - if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first(): - raise HTTPException(status_code=404, detail="Office not found") - target.office_id = data.office_id + # Manager assignment (any user including admins can be assigned to a manager) + if data.manager_id is not None: + if data.manager_id: + manager = db.query(User).filter(User.id == data.manager_id).first() + if not manager or manager.role != "manager": + raise HTTPException(status_code=400, detail="Invalid manager") + if data.manager_id == user_id: + raise HTTPException(status_code=400, detail="User cannot be their own manager") + target.manager_id = data.manager_id if data.manager_id else None + # Manager-specific fields if data.manager_parking_quota is not None: if target.role != "manager": raise HTTPException(status_code=400, detail="Parking quota only for managers") @@ -166,7 +226,7 @@ def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), u target.updated_at = datetime.utcnow().isoformat() db.commit() db.refresh(target) - return target + return user_to_response(target, db) @router.delete("/{user_id}") @@ -179,60 +239,53 @@ def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depend if not target: raise HTTPException(status_code=404, detail="User not found") + # Check if user is a manager with managed users + if target.role == "manager": + managed_count = db.query(User).filter(User.manager_id == user_id).count() + if managed_count > 0: + raise HTTPException(status_code=400, detail=f"Cannot delete: {managed_count} users are assigned to this manager") + db.delete(target) db.commit() return {"message": "User deleted"} # Self-service Routes -@router.get("/me/managed-offices") -def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Get offices the current user manages""" - if current_user.role == "admin": - offices = db.query(Office).all() - return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]} - - if current_user.role == "manager": - memberships = db.query(OfficeMembership).filter( - OfficeMembership.user_id == current_user.id - ).all() - office_ids = [m.office_id for m in memberships] - offices = db.query(Office).filter(Office.id.in_(office_ids)).all() - return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]} - - if current_user.office_id: - office = db.query(Office).filter(Office.id == current_user.office_id).first() - if office: - return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]} - - return {"role": current_user.role, "offices": []} - - @router.get("/me/profile") -def get_profile(current_user=Depends(get_current_user)): +def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)): """Get current user's profile""" + is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None + + # Get manager name + manager_name = None + if current_user.manager_id: + manager = db.query(User).filter(User.id == current_user.manager_id).first() + if manager: + manager_name = manager.name + return { "id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role, - "office_id": current_user.office_id + "manager_id": current_user.manager_id, + "manager_name": manager_name, + "is_ldap_user": is_ldap_user } @router.put("/me/profile") def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Update current user's profile""" + """Update current user's profile (limited fields)""" + is_ldap_user = config.AUTHELIA_ENABLED and current_user.password_hash is None + if data.name is not None: + if is_ldap_user: + raise HTTPException(status_code=400, detail="Name is managed by LDAP") current_user.name = data.name + current_user.updated_at = datetime.utcnow().isoformat() + db.commit() - if data.office_id is not None: - if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first(): - raise HTTPException(status_code=404, detail="Office not found") - current_user.office_id = data.office_id if data.office_id else None - - current_user.updated_at = datetime.utcnow().isoformat() - db.commit() return {"message": "Profile updated"} @@ -292,7 +345,10 @@ def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current @router.post("/me/change-password") def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)): - """Change current user's password""" + """Change current user's password (not available in LDAP mode)""" + if config.AUTHELIA_ENABLED and current_user.password_hash is None: + raise HTTPException(status_code=400, detail="Password is managed by LDAP") + if not verify_password(data.current_password, current_user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect") diff --git a/compose.yml b/compose.yml index f91f8b6..7799ec3 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,7 @@ services: parking: build: . - container_name: parking-manager + container_name: parking restart: unless-stopped ports: - "8000:8000" @@ -26,9 +26,10 @@ services: timeout: 10s retries: 3 start_period: 10s + networks: + - org-network -# For production with external network (Caddy/Authelia): -# networks: -# default: -# external: true -# name: proxy +networks: + org-network: + external: true + name: org-stack_org-network diff --git a/create_test_db.py b/create_test_db.py index 2193f54..dacb070 100644 --- a/create_test_db.py +++ b/create_test_db.py @@ -3,19 +3,14 @@ Create test database with sample data Run: .venv/bin/python create_test_db.py Manager-centric model: +- Users have a manager_id pointing to their manager - Managers own parking spots (manager_parking_quota) - Each manager has a spot prefix (A, B, C...) for display names -- Offices are containers for employees (like LDAP groups) - -LDAP Group Simulation: -- parking_admin: admin role -- parking_manager: manager role -- office groups (presales, design, operations): employee office assignment """ import uuid from datetime import datetime, timezone from database.connection import engine, SessionLocal -from database.models import Base, User, Office, OfficeMembership +from database.models import Base, User from services.auth import hash_password # Drop and recreate all tables for clean slate @@ -27,29 +22,8 @@ db = SessionLocal() now = datetime.now(timezone.utc).isoformat() password_hash = hash_password("password123") -# Create offices (representing LDAP groups) -offices = [ - Office(id="presales", name="Presales", location="Building A", created_at=now), - Office(id="design", name="Design", location="Building B", created_at=now), - Office(id="operations", name="Operations", location="Building C", created_at=now), -] - -for office in offices: - db.add(office) - print(f"Created office: {office.name}") - -db.commit() - -# Create users -# LDAP groups simulation: -# admin: parking_admin, presales -# manager1: parking_manager, presales, design -# manager2: parking_manager, operations -# user1: presales -# user2: design -# user3: design -# user4: operations -# user5: operations +# Create users with manager-centric model +# manager_id points to the user's manager users_data = [ { @@ -57,15 +31,15 @@ users_data = [ "email": "admin@example.com", "name": "Admin User", "role": "admin", - "office_id": "presales", # Primary office from LDAP groups + "manager_id": None, # Admins don't have managers }, { "id": "manager1", "email": "manager1@example.com", "name": "Alice Manager", "role": "manager", - "office_id": "presales", - "manager_parking_quota": 3, # For 5 users: admin, alice, user1, user2, user3 + "manager_id": None, # Managers don't have managers + "manager_parking_quota": 3, "manager_spot_prefix": "A", }, { @@ -73,8 +47,8 @@ users_data = [ "email": "manager2@example.com", "name": "Bob Manager", "role": "manager", - "office_id": "operations", - "manager_parking_quota": 2, # For 3 users: bob, user4, user5 + "manager_id": None, + "manager_parking_quota": 2, "manager_spot_prefix": "B", }, { @@ -82,35 +56,35 @@ users_data = [ "email": "user1@example.com", "name": "User One", "role": "employee", - "office_id": "presales", + "manager_id": "manager1", # Managed by Alice }, { "id": "user2", "email": "user2@example.com", "name": "User Two", "role": "employee", - "office_id": "design", + "manager_id": "manager1", # Managed by Alice }, { "id": "user3", "email": "user3@example.com", "name": "User Three", "role": "employee", - "office_id": "design", + "manager_id": "manager1", # Managed by Alice }, { "id": "user4", "email": "user4@example.com", "name": "User Four", "role": "employee", - "office_id": "operations", + "manager_id": "manager2", # Managed by Bob }, { "id": "user5", "email": "user5@example.com", "name": "User Five", "role": "employee", - "office_id": "operations", + "manager_id": "manager2", # Managed by Bob }, ] @@ -121,7 +95,7 @@ for data in users_data: password_hash=password_hash, name=data["name"], role=data["role"], - office_id=data.get("office_id"), + manager_id=data.get("manager_id"), manager_parking_quota=data.get("manager_parking_quota", 0), manager_spot_prefix=data.get("manager_spot_prefix"), created_at=now @@ -129,30 +103,6 @@ for data in users_data: db.add(user) print(f"Created user: {user.email} ({user.role})") -db.commit() - -# Create office memberships for managers -# Manager 1 (Alice) manages Presales and Design -for office_id in ["presales", "design"]: - membership = OfficeMembership( - id=str(uuid.uuid4()), - user_id="manager1", - office_id=office_id, - created_at=now - ) - db.add(membership) - print(f"Created membership: Alice -> {office_id}") - -# Manager 2 (Bob) manages Operations -membership = OfficeMembership( - id=str(uuid.uuid4()), - user_id="manager2", - office_id="operations", - created_at=now -) -db.add(membership) -print(f"Created membership: Bob -> operations") - db.commit() db.close() @@ -161,23 +111,22 @@ print("Test database created successfully!") print("="*60) print("\nTest accounts (all use password: password123):") print("-"*60) -print(f"{'Email':<25} {'Role':<10} {'Office':<12} {'Spots'}") +print(f"{'Email':<25} {'Role':<10} {'Manager':<15}") print("-"*60) -print(f"{'admin@example.com':<25} {'admin':<10} {'presales':<12}") -print(f"{'manager1@example.com':<25} {'manager':<10} {'presales':<12}") -print(f"{'manager2@example.com':<25} {'manager':<10} {'operations':<12}") -print(f"{'user1@example.com':<25} {'employee':<10} {'presales':<12}") -print(f"{'user2@example.com':<25} {'employee':<10} {'design':<12}") -print(f"{'user3@example.com':<25} {'employee':<10} {'design':<12}") -print(f"{'user4@example.com':<25} {'employee':<10} {'operations':<12}") -print(f"{'user5@example.com':<25} {'employee':<10} {'operations':<12}") +print(f"{'admin@example.com':<25} {'admin':<10} {'-':<15}") +print(f"{'manager1@example.com':<25} {'manager':<10} {'-':<15}") +print(f"{'manager2@example.com':<25} {'manager':<10} {'-':<15}") +print(f"{'user1@example.com':<25} {'employee':<10} {'Alice':<15}") +print(f"{'user2@example.com':<25} {'employee':<10} {'Alice':<15}") +print(f"{'user3@example.com':<25} {'employee':<10} {'Alice':<15}") +print(f"{'user4@example.com':<25} {'employee':<10} {'Bob':<15}") +print(f"{'user5@example.com':<25} {'employee':<10} {'Bob':<15}") print("-"*60) print("\nParking pools:") print(" Alice (manager1): 3 spots (A1,A2,A3)") -print(" -> presales: admin, alice, user1") -print(" -> design: user2, user3") -print(" -> 5 users, 3 spots = 60% ratio target") +print(" -> manages: user1, user2, user3") +print(" -> 3 users, 3 spots = 100% ratio target") print() print(" Bob (manager2): 2 spots (B1,B2)") -print(" -> operations: bob, user4, user5") -print(" -> 3 users, 2 spots = 67% ratio target") +print(" -> manages: user4, user5") +print(" -> 2 users, 2 spots = 100% ratio target") diff --git a/database/connection.py b/database/connection.py index a9520bd..5d2d3c7 100644 --- a/database/connection.py +++ b/database/connection.py @@ -36,4 +36,7 @@ def get_db_session(): def init_db(): """Create all tables""" from database.models import Base + + print(f"[init_db] Initializing database at {config.DATABASE_URL}") Base.metadata.create_all(bind=engine) + print(f"[init_db] Tables created: {list(Base.metadata.tables.keys())}") diff --git a/database/models.py b/database/models.py index f03196e..de3f2df 100644 --- a/database/models.py +++ b/database/models.py @@ -17,7 +17,7 @@ class User(Base): password_hash = Column(Text) name = Column(Text) role = Column(Text, nullable=False, default="employee") # admin, manager, employee - office_id = Column(Text, ForeignKey("offices.id")) + manager_id = Column(Text, ForeignKey("users.id")) # Who manages this user (any user can have a manager) # Manager-specific fields (only relevant for role='manager') manager_parking_quota = Column(Integer, default=0) # Number of spots this manager controls @@ -37,51 +37,13 @@ class User(Base): updated_at = Column(Text) # Relationships - office = relationship("Office", back_populates="users") + manager = relationship("User", remote_side=[id], backref="managed_users") presences = relationship("UserPresence", back_populates="user", cascade="all, delete-orphan") assignments = relationship("DailyParkingAssignment", back_populates="user", foreign_keys="DailyParkingAssignment.user_id") - managed_offices = relationship("OfficeMembership", back_populates="user", cascade="all, delete-orphan") __table_args__ = ( Index('idx_user_email', 'email'), - Index('idx_user_office', 'office_id'), - ) - - -class Office(Base): - """Office locations - containers for grouping employees""" - __tablename__ = "offices" - - id = Column(Text, primary_key=True) - name = Column(Text, nullable=False) - location = Column(Text) - # Note: parking_spots removed - spots are now managed at manager level - - created_at = Column(Text) - updated_at = Column(Text) - - # Relationships - users = relationship("User", back_populates="office") - managers = relationship("OfficeMembership", back_populates="office", cascade="all, delete-orphan") - - -class OfficeMembership(Base): - """Manager-Office relationship (which managers manage which offices)""" - __tablename__ = "office_memberships" - - id = Column(Text, primary_key=True) - user_id = Column(Text, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - office_id = Column(Text, ForeignKey("offices.id", ondelete="CASCADE"), nullable=False) - created_at = Column(Text) - - # Relationships - user = relationship("User", back_populates="managed_offices") - office = relationship("Office", back_populates="managers") - - __table_args__ = ( - Index('idx_membership_user', 'user_id'), - Index('idx_membership_office', 'office_id'), - Index('idx_membership_unique', 'user_id', 'office_id', unique=True), + Index('idx_user_manager', 'manager_id'), ) @@ -128,7 +90,7 @@ class DailyParkingAssignment(Base): class ManagerClosingDay(Base): - """Specific date closing days for a manager's offices (holidays, special closures)""" + """Specific date closing days for a manager's parking pool (holidays, special closures)""" __tablename__ = "manager_closing_days" id = Column(Text, primary_key=True) @@ -145,7 +107,7 @@ class ManagerClosingDay(Base): class ManagerWeeklyClosingDay(Base): - """Weekly recurring closing days for a manager's offices (e.g., Saturday and Sunday)""" + """Weekly recurring closing days for a manager's parking pool (e.g., Saturday and Sunday)""" __tablename__ = "manager_weekly_closing_days" id = Column(Text, primary_key=True) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 565aa7f..07e9a20 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -3,55 +3,52 @@ ## Prerequisites - org-stack running on rocky@rocketscale.it -- Git repository on git.rocketscale.it +- Git repository on git.rocketscale.it (optional, can use rsync) -## Step 1: Push to Git +## Directory Structure -```bash -# On development machine -cd /mnt/code/boilerplate/org-parking -git init -git add . -git commit -m "Initial commit: Parking Manager" -git remote add origin git@git.rocketscale.it:rocky/parking-manager.git -git push -u origin main +Parking is deployed as a **separate directory** alongside org-stack: + +``` +~/ +├── org-stack/ # Main stack (Caddy, Authelia, LLDAP, etc.) +│ ├── compose.yml +│ ├── Caddyfile +│ ├── authelia/ +│ └── .env +│ +└── org-parking/ # Parking app (separate) + ├── compose.yml # Production compose (connects to org-stack network) + ├── .env # Own .env with PARKING_SECRET_KEY + ├── Dockerfile + └── ... ``` -## Step 2: Clone on Server +## Step 1: Deploy to Server +Option A - Using rsync (recommended for development): +```bash +# From development machine +rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ + --exclude '.env' --exclude 'data/' --exclude '*.db' --exclude '.venv/' \ + /path/to/org-parking/ rocky@rocketscale.it:~/org-parking/ +``` + +Option B - Using git: ```bash -# SSH to server ssh rocky@rocketscale.it - -# Clone into org-stack -cd ~/org-stack -git clone git@git.rocketscale.it:rocky/parking-manager.git parking +cd ~ +git clone git@git.rocketscale.it:rocky/parking-manager.git org-parking ``` -## Step 3: Add to .env +## Step 2: Create Production compose.yml -Add to `~/org-stack/.env`: - -```bash -# Parking Manager -PARKING_SECRET_KEY=your-random-secret-key-here -``` - -Generate a secret key: -```bash -python3 -c "import secrets; print(secrets.token_hex(32))" -``` - -## Step 4: Add to compose.yml - -Add the parking service to `~/org-stack/compose.yml`: +Create `~/org-parking/compose.yml` on the server: ```yaml - # =========================================================================== - # Parking Manager - Parking Spot Management - # =========================================================================== +services: parking: - build: ./parking + build: . container_name: parking restart: unless-stopped volumes: @@ -68,18 +65,34 @@ Add the parking service to `~/org-stack/compose.yml`: - SMTP_FROM=${SMTP_FROM:-} networks: - org-network - depends_on: - - authelia + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + parking_data: + +networks: + org-network: + external: true + name: org-stack_org-network ``` -Add to volumes section: -```yaml - parking_data: # Parking SQLite database +## Step 3: Create .env File + +Create `~/org-parking/.env` with a secret key: + +```bash +cd ~/org-parking +python3 -c "import secrets; print(f'PARKING_SECRET_KEY={secrets.token_hex(32)}')" > .env ``` -Add `parking` to Caddy's depends_on list. +**Note**: Each directory needs its own `.env` file since docker compose only reads from the current directory. -## Step 5: Add to Caddyfile +## Step 4: Add to Caddyfile Add to `~/org-stack/Caddyfile`: @@ -91,27 +104,57 @@ parking.rocketscale.it { } ``` -## Step 6: Create LLDAP Groups +## Step 5: Add Authelia Access Control Rule + +**Important**: Authelia's `access_control` must include parking.rocketscale.it or you'll get 403 Forbidden. + +Edit `~/org-stack/authelia/configuration.yml` and add to the `access_control.rules` section: + +```yaml +access_control: + default_policy: deny + rules: + # ... existing rules ... + + # Parking Manager - require authentication + - domain: parking.rocketscale.it + policy: one_factor +``` + +After editing, restart Authelia: +```bash +cd ~/org-stack +docker compose restart authelia +``` + +## Step 6: Create LLDAP Group In lldap (https://ldap.rocketscale.it): -1. Create group: `parking_admins` (follows lldap naming convention) -2. Create group: `managers` (reusable across apps) -3. Add yourself to `parking_admins` +1. Create group: `parking_admins` +2. Add yourself (or whoever should be admin) to this group -## Step 7: Deploy +**Role Management:** +- `parking_admins` group → **admin** role (synced from LLDAP on each login) +- **manager** role → assigned by admin in the app UI (Manage Users page) +- **employee** role → default for all other users + +The admin can promote users to manager and assign offices via the Manage Users page. + +## Step 7: Build and Deploy ```bash -cd ~/org-stack - # Build and start parking service +cd ~/org-parking docker compose build parking -docker compose up -d parking +docker compose up -d -# Reload Caddy to pick up new domain +# Reload Caddy to pick up new domain (from org-stack) +cd ~/org-stack docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile # Check logs +cd ~/org-parking docker compose logs -f parking ``` @@ -124,14 +167,53 @@ docker compose logs -f parking ## Troubleshooting +### 403 Forbidden from Authelia +- Authelia's `access_control` doesn't have a rule for parking.rocketscale.it +- Add the domain to `~/org-stack/authelia/configuration.yml` (see Step 6) +- Restart Authelia: `docker compose restart authelia` + ### 401 Unauthorized - Check Authelia headers are being passed - Check `docker compose logs authelia` +### Login redirect loop (keeps redirecting to /login) +- Frontend JS must use async auth checking for Authelia mode +- The `api.requireAuth()` must call `/api/auth/me` endpoint (not check localStorage) +- Ensure all page JS files use: `currentUser = await api.requireAuth();` + ### User has wrong role -- Verify LLDAP group membership -- Roles sync on each login +- **Admin role**: Verify user is in `parking_admins` LLDAP group (synced on each login) +- **Manager role**: Must be assigned by admin via Manage Users page (not from LLDAP) +- **Employee role**: Default for users not in `parking_admins` group ### Database errors - Check volume mount: `docker compose exec parking ls -la /app/data` - Check permissions: `docker compose exec parking id` + +## Architecture Notes + +### Authelia Integration +The app supports two authentication modes: +1. **JWT mode** (standalone): Users login via `/login`, get JWT token stored in localStorage +2. **Authelia mode** (SSO): Authelia handles login, passes headers to backend + +When `AUTHELIA_ENABLED=true`: +- Backend reads user info from headers: `Remote-User`, `Remote-Email`, `Remote-Name`, `Remote-Groups` +- Users are auto-created on first login +- Roles are synced from LLDAP groups on each request +- Frontend calls `/api/auth/me` to get user info (backend reads headers) + +### Role Management + +Only the **admin** role is synced from LLDAP: +```python +AUTHELIA_ADMIN_GROUP = "parking_admins" # → role: admin +``` + +Other roles are managed within the app: +- **manager**: Assigned by admin via Manage Users page +- **employee**: Default role for all non-admin users + +This separation allows: +- LLDAP to control who has admin access +- App admin to assign manager roles and office assignments without LLDAP changes diff --git a/frontend/js/admin-users.js b/frontend/js/admin-users.js index e609e8f..b8dfd2a 100644 --- a/frontend/js/admin-users.js +++ b/frontend/js/admin-users.js @@ -1,141 +1,138 @@ /** * Admin Users Page - * Manage all users in the system + * Manage users with LDAP-aware editing */ let currentUser = null; let users = []; -let offices = []; -let managedOfficesMap = {}; // user_id -> [office_ids] +let managers = []; document.addEventListener('DOMContentLoaded', async () => { - if (!api.requireAuth()) return; - - currentUser = await api.getCurrentUser(); + currentUser = await api.requireAuth(); if (!currentUser) return; - // Only admins can access if (currentUser.role !== 'admin') { window.location.href = '/presence'; return; } - await Promise.all([loadUsers(), loadOffices()]); - await loadManagedOffices(); - renderUsers(); + await loadManagers(); + await loadUsers(); setupEventListeners(); }); +async function loadManagers() { + const response = await api.get('/api/managers'); + if (response && response.ok) { + managers = await response.json(); + } +} + async function loadUsers() { const response = await api.get('/api/users'); if (response && response.ok) { users = await response.json(); + renderUsers(); } } -async function loadOffices() { - const response = await api.get('/api/offices'); - if (response && response.ok) { - offices = await response.json(); - const select = document.getElementById('editOffice'); - offices.forEach(office => { - const option = document.createElement('option'); - option.value = office.id; - option.textContent = office.name; - select.appendChild(option); - }); - } -} - -async function loadManagedOffices() { - // Load managed offices for all managers - const response = await api.get('/api/offices/managers/list'); - if (response && response.ok) { - const managers = await response.json(); - managedOfficesMap = {}; - managers.forEach(m => { - managedOfficesMap[m.id] = m.offices.map(o => o.id); - }); - } -} - -function getManagedOfficeNames(userId) { - const officeIds = managedOfficesMap[userId] || []; - if (officeIds.length === 0) return '-'; - return officeIds.map(id => { - const office = offices.find(o => o.id === id); - return office ? office.name : id; - }).join(', '); -} - function renderUsers(filter = '') { const tbody = document.getElementById('usersBody'); - const filtered = filter - ? users.filter(u => - u.name.toLowerCase().includes(filter.toLowerCase()) || - u.email.toLowerCase().includes(filter.toLowerCase()) - ) - : users; + const filterLower = filter.toLowerCase(); + + let filtered = users; + if (filter) { + filtered = users.filter(u => + (u.name || '').toLowerCase().includes(filterLower) || + (u.email || '').toLowerCase().includes(filterLower) || + (u.role || '').toLowerCase().includes(filterLower) || + (u.manager_name || '').toLowerCase().includes(filterLower) + ); + } if (filtered.length === 0) { - tbody.innerHTML = 'No users found'; + tbody.innerHTML = 'No users found'; return; } tbody.innerHTML = filtered.map(user => { - const office = offices.find(o => o.id === user.office_id); - const isManager = user.role === 'manager'; - const managedOffices = isManager ? getManagedOfficeNames(user.id) : '-'; + const ldapBadge = user.is_ldap_user ? 'LDAP' : ''; + const managerInfo = user.role === 'manager' + ? `${user.managed_user_count || 0} users` + : (user.manager_name || '-'); + return ` - ${user.name} + ${user.name || '-'} ${ldapBadge} ${user.email} - ${user.role} - ${office ? office.name : '-'} - ${managedOffices} - ${isManager ? (user.manager_parking_quota || 0) : '-'} - ${isManager ? (user.manager_spot_prefix || '-') : '-'} + ${user.role} + ${managerInfo} - ${user.id !== currentUser.id ? ` - - ` : ''} + `; }).join(''); } -function editUser(userId) { +function getRoleBadgeClass(role) { + switch (role) { + case 'admin': return 'danger'; + case 'manager': return 'warning'; + default: return 'secondary'; + } +} + +async function editUser(userId) { const user = users.find(u => u.id === userId); if (!user) return; + // Populate form document.getElementById('userId').value = user.id; - document.getElementById('editName').value = user.name; + document.getElementById('editName').value = user.name || ''; document.getElementById('editEmail').value = user.email; document.getElementById('editRole').value = user.role; - document.getElementById('editOffice').value = user.office_id || ''; document.getElementById('editQuota').value = user.manager_parking_quota || 0; document.getElementById('editPrefix').value = user.manager_spot_prefix || ''; - const isManager = user.role === 'manager'; + // Populate manager dropdown + const managerSelect = document.getElementById('editManager'); + managerSelect.innerHTML = ''; + managers.forEach(m => { + if (m.id !== userId) { // Can't be own manager + const option = document.createElement('option'); + option.value = m.id; + option.textContent = m.name; + if (m.id === user.manager_id) option.selected = true; + managerSelect.appendChild(option); + } + }); - // Show/hide manager fields based on role - document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none'; - document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none'; - document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none'; + // Handle LDAP restrictions + const isLdap = user.is_ldap_user; + const isLdapAdmin = user.is_ldap_admin; - // Build managed offices checkboxes - const checkboxContainer = document.getElementById('managedOfficesCheckboxes'); - const userManagedOffices = managedOfficesMap[userId] || []; - checkboxContainer.innerHTML = offices.map(office => ` - - `).join(''); + // LDAP notice + document.getElementById('ldapNotice').style.display = isLdap ? 'block' : 'none'; + // Name field - disabled for LDAP users + const nameInput = document.getElementById('editName'); + nameInput.disabled = isLdap; + document.getElementById('nameHelp').style.display = isLdap ? 'block' : 'none'; + + // Role field - admin option disabled for LDAP admins (they can't be demoted) + const roleSelect = document.getElementById('editRole'); + roleSelect.disabled = isLdapAdmin; + document.getElementById('roleHelp').style.display = isLdapAdmin ? 'block' : 'none'; + + // Manager group - show for all users (admins can also be assigned to a manager) + document.getElementById('managerGroup').style.display = 'block'; + + // Manager fields - show only for managers + document.getElementById('managerFields').style.display = user.role === 'manager' ? 'block' : 'none'; + + document.getElementById('userModalTitle').textContent = 'Edit User'; document.getElementById('userModal').style.display = 'flex'; } @@ -143,14 +140,12 @@ async function deleteUser(userId) { const user = users.find(u => u.id === userId); if (!user) return; - if (!confirm(`Delete user "${user.name}"? This cannot be undone.`)) return; + if (!confirm(`Delete user "${user.name || user.email}"?`)) return; const response = await api.delete(`/api/users/${userId}`); if (response && response.ok) { - await loadUsers(); - await loadManagedOffices(); - renderUsers(); utils.showMessage('User deleted', 'success'); + await loadUsers(); } else { const error = await response.json(); utils.showMessage(error.detail || 'Failed to delete user', 'error'); @@ -163,22 +158,21 @@ function setupEventListeners() { renderUsers(e.target.value); }); - // Modal + // Role change - toggle manager fields (manager group always visible since any user can have a manager) + document.getElementById('editRole').addEventListener('change', (e) => { + const role = e.target.value; + document.getElementById('managerFields').style.display = role === 'manager' ? 'block' : 'none'; + // Manager group stays visible - any user (including admins) can have a manager assigned + }); + + // Modal close document.getElementById('closeUserModal').addEventListener('click', () => { document.getElementById('userModal').style.display = 'none'; }); - document.getElementById('cancelUser').addEventListener('click', () => { document.getElementById('userModal').style.display = 'none'; }); - - // Role change shows/hides manager fields - document.getElementById('editRole').addEventListener('change', (e) => { - const isManager = e.target.value === 'manager'; - document.getElementById('quotaGroup').style.display = isManager ? 'block' : 'none'; - document.getElementById('prefixGroup').style.display = isManager ? 'block' : 'none'; - document.getElementById('managedOfficesGroup').style.display = isManager ? 'block' : 'none'; - }); + utils.setupModalClose('userModal'); // Form submit document.getElementById('userForm').addEventListener('submit', async (e) => { @@ -188,60 +182,35 @@ function setupEventListeners() { const role = document.getElementById('editRole').value; const data = { - name: document.getElementById('editName').value, role: role, - office_id: document.getElementById('editOffice').value || null + manager_id: document.getElementById('editManager').value || null }; + // Only include name if not disabled (LDAP users can't change name) + const nameInput = document.getElementById('editName'); + if (!nameInput.disabled) { + data.name = nameInput.value; + } + + // Manager-specific fields if (role === 'manager') { data.manager_parking_quota = parseInt(document.getElementById('editQuota').value) || 0; - data.manager_spot_prefix = document.getElementById('editPrefix').value.toUpperCase() || null; + data.manager_spot_prefix = document.getElementById('editPrefix').value || null; } const response = await api.put(`/api/users/${userId}`, data); if (response && response.ok) { - // Update managed offices if manager - if (role === 'manager') { - await updateManagedOffices(userId); - } - document.getElementById('userModal').style.display = 'none'; - await loadUsers(); - await loadManagedOffices(); - renderUsers(); utils.showMessage('User updated', 'success'); + await loadManagers(); // Reload in case role changed + await loadUsers(); } else { const error = await response.json(); utils.showMessage(error.detail || 'Failed to update user', 'error'); } }); - - utils.setupModalClose('userModal'); } -async function updateManagedOffices(userId) { - // Get currently selected offices - const checkboxes = document.querySelectorAll('input[name="managedOffice"]:checked'); - const selectedOfficeIds = Array.from(checkboxes).map(cb => cb.value); - - // Get current managed offices - const currentOfficeIds = managedOfficesMap[userId] || []; - - // Find offices to add and remove - const toAdd = selectedOfficeIds.filter(id => !currentOfficeIds.includes(id)); - const toRemove = currentOfficeIds.filter(id => !selectedOfficeIds.includes(id)); - - // Add new memberships - for (const officeId of toAdd) { - await api.post(`/api/offices/${officeId}/managers`, { user_id: userId }); - } - - // Remove old memberships - for (const officeId of toRemove) { - await api.delete(`/api/offices/${officeId}/managers/${userId}`); - } -} - -// Make functions globally accessible +// Make functions available globally for onclick handlers window.editUser = editUser; window.deleteUser = deleteUser; diff --git a/frontend/js/api.js b/frontend/js/api.js index fe29646..896bf4e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -26,21 +26,42 @@ const api = { }, /** - * Check if user is authenticated + * Check if user is authenticated (token or Authelia) */ isAuthenticated() { - return !!this.getToken(); + return !!this.getToken() || this._autheliaAuth; + }, + + /** + * Check authentication - works with both JWT and Authelia + * Call this on page load to verify auth status + */ + async checkAuth() { + // Try to get current user - works with Authelia headers or JWT + const response = await fetch('/api/auth/me', { + headers: this.getToken() ? { 'Authorization': `Bearer ${this.getToken()}` } : {} + }); + + if (response.ok) { + this._autheliaAuth = true; + return await response.json(); + } + + this._autheliaAuth = false; + return null; }, /** * Redirect to login if not authenticated + * Returns user object if authenticated, null otherwise */ - requireAuth() { - if (!this.isAuthenticated()) { + async requireAuth() { + const user = await this.checkAuth(); + if (!user) { window.location.href = '/login'; - return false; + return null; } - return true; + return user; }, /** @@ -143,11 +164,11 @@ const api = { /** * Register */ - async register(email, password, name, officeId = null) { + async register(email, password, name) { const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, name, office_id: officeId }) + body: JSON.stringify({ email, password, name }) }); if (response.ok) { diff --git a/frontend/js/nav.js b/frontend/js/nav.js index f9de41d..d11b891 100644 --- a/frontend/js/nav.js +++ b/frontend/js/nav.js @@ -43,8 +43,8 @@ const ICONS = { const NAV_ITEMS = [ { href: '/presence', icon: 'calendar', label: 'My Presence' }, - { href: '/team-calendar', icon: 'users', label: 'Team Calendar', roles: ['admin', 'manager'] }, - { href: '/office-rules', icon: 'rules', label: 'Office Rules', roles: ['admin', 'manager'] }, + { href: '/team-calendar', icon: 'users', label: 'Team Calendar' }, + { href: '/team-rules', icon: 'rules', label: 'Team Rules', roles: ['admin', 'manager'] }, { href: '/admin/users', icon: 'user', label: 'Manage Users', roles: ['admin'] } ]; @@ -77,26 +77,19 @@ async function initNav() { if (!navContainer) return; const currentPath = window.location.pathname; - let userRole = null; - let currentUser = null; - // Get user info - if (api.isAuthenticated()) { - currentUser = await api.getCurrentUser(); - if (currentUser) { - userRole = currentUser.role; - } - } + // Get user info (works with both JWT and Authelia) + const currentUser = await api.checkAuth(); // Render navigation - navContainer.innerHTML = renderNav(currentPath, userRole); + navContainer.innerHTML = renderNav(currentPath, currentUser?.role); // Update user info in sidebar if (currentUser) { - const userName = document.getElementById('userName'); - const userRole = document.getElementById('userRole'); - if (userName) userName.textContent = currentUser.name || 'User'; - if (userRole) userRole.textContent = currentUser.role || '-'; + const userNameEl = document.getElementById('userName'); + const userRoleEl = document.getElementById('userRole'); + if (userNameEl) userNameEl.textContent = currentUser.name || 'User'; + if (userRoleEl) userRoleEl.textContent = currentUser.role || '-'; } // Setup user menu diff --git a/frontend/js/presence.js b/frontend/js/presence.js index 500820f..7d5322f 100644 --- a/frontend/js/presence.js +++ b/frontend/js/presence.js @@ -10,9 +10,7 @@ let parkingData = {}; let currentAssignmentId = null; document.addEventListener('DOMContentLoaded', async () => { - if (!api.requireAuth()) return; - - currentUser = await api.getCurrentUser(); + currentUser = await api.requireAuth(); if (!currentUser) return; await Promise.all([loadPresences(), loadParkingAssignments()]); diff --git a/frontend/js/team-calendar.js b/frontend/js/team-calendar.js index 2fd5e03..c8eaa59 100644 --- a/frontend/js/team-calendar.js +++ b/frontend/js/team-calendar.js @@ -16,17 +16,9 @@ let selectedDate = null; let currentAssignmentId = null; document.addEventListener('DOMContentLoaded', async () => { - if (!api.requireAuth()) return; - - currentUser = await api.getCurrentUser(); + currentUser = await api.requireAuth(); if (!currentUser) return; - // Only managers and admins can view - if (currentUser.role === 'employee') { - window.location.href = '/presence'; - return; - } - // Initialize start date based on week start preference const weekStartDay = currentUser.week_start_day || 0; currentStartDate = utils.getWeekStart(new Date(), weekStartDay); @@ -38,7 +30,7 @@ document.addEventListener('DOMContentLoaded', async () => { }); async function loadManagers() { - const response = await api.get('/api/offices/managers/list'); + const response = await api.get('/api/managers'); if (response && response.ok) { managers = await response.json(); const select = document.getElementById('managerFilter'); @@ -48,20 +40,32 @@ async function loadManagers() { if (currentUser.role === 'manager') { // Manager only sees themselves filteredManagers = managers.filter(m => m.id === currentUser.id); + } else if (currentUser.role === 'employee') { + // Employee only sees their own manager + if (currentUser.manager_id) { + filteredManagers = managers.filter(m => m.id === currentUser.manager_id); + } else { + filteredManagers = []; + } } filteredManagers.forEach(manager => { const option = document.createElement('option'); option.value = manager.id; - const officeNames = manager.offices.map(o => o.name).join(', '); - option.textContent = `${manager.name} (${officeNames})`; + const userCount = manager.managed_user_count || 0; + option.textContent = `${manager.name} (${userCount} users)`; select.appendChild(option); }); - // Auto-select if only one manager (for manager role) + // Auto-select for managers and employees (they only see their team) if (filteredManagers.length === 1) { select.value = filteredManagers[0].id; } + + // Hide manager filter for employees (they can only see their team) + if (currentUser.role === 'employee') { + select.style.display = 'none'; + } } } @@ -129,7 +133,7 @@ function renderCalendar() { // Build header row const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - let headerHtml = 'NameOffice'; + let headerHtml = 'NameManager'; for (let i = 0; i < dayCount; i++) { const date = new Date(startDate); @@ -161,7 +165,7 @@ function renderCalendar() { teamData.forEach(member => { bodyHtml += ` ${member.name || 'Unknown'} - ${member.office_name || '-'}`; + ${member.manager_name || '-'}`; for (let i = 0; i < dayCount; i++) { const date = new Date(startDate); @@ -197,16 +201,18 @@ function renderCalendar() { }); body.innerHTML = bodyHtml; - // Add click handlers to cells - body.querySelectorAll('.calendar-cell').forEach(cell => { - cell.style.cursor = 'pointer'; - cell.addEventListener('click', () => { - const userId = cell.dataset.userId; - const date = cell.dataset.date; - const userName = cell.dataset.userName; - openDayModal(userId, date, userName); + // Add click handlers to cells (only for admins and managers) + if (currentUser.role === 'admin' || currentUser.role === 'manager') { + body.querySelectorAll('.calendar-cell').forEach(cell => { + cell.style.cursor = 'pointer'; + cell.addEventListener('click', () => { + const userId = cell.dataset.userId; + const date = cell.dataset.date; + const userName = cell.dataset.userName; + openDayModal(userId, date, userName); + }); }); - }); + } } function openDayModal(userId, dateStr, userName) { diff --git a/frontend/js/office-rules.js b/frontend/js/team-rules.js similarity index 83% rename from frontend/js/office-rules.js rename to frontend/js/team-rules.js index c500489..a812ec4 100644 --- a/frontend/js/office-rules.js +++ b/frontend/js/team-rules.js @@ -1,8 +1,8 @@ /** - * Office Rules Page + * Team Rules Page * Manage closing days, parking guarantees, and exclusions * - * Rules are set at manager level and apply to all offices managed by that manager. + * Rules are set at manager level for their parking pool. */ let currentUser = null; @@ -10,9 +10,7 @@ let selectedManagerId = null; let managerUsers = []; document.addEventListener('DOMContentLoaded', async () => { - if (!api.requireAuth()) return; - - currentUser = await api.getCurrentUser(); + currentUser = await api.requireAuth(); if (!currentUser) return; // Only managers and admins can access @@ -26,10 +24,10 @@ document.addEventListener('DOMContentLoaded', async () => { }); async function loadManagers() { - const response = await api.get('/api/offices/managers/list'); + const response = await api.get('/api/managers'); if (response && response.ok) { const managers = await response.json(); - const select = document.getElementById('officeSelect'); + const select = document.getElementById('managerSelect'); // Filter to managers this user can see let filteredManagers = managers; @@ -45,14 +43,10 @@ async function loadManagers() { filteredManagers.forEach(manager => { const option = document.createElement('option'); option.value = manager.id; - // Show manager name and count of offices - const officeCount = manager.offices.length; - const officeNames = manager.offices.map(o => o.name).join(', '); - if (officeCount > 0) { - option.textContent = `${manager.name} (${officeNames})`; - } else { - option.textContent = `${manager.name} (no offices)`; - } + // Show manager name with user count and parking quota + const userCount = manager.managed_user_count || 0; + const quota = manager.parking_quota || 0; + option.textContent = `${manager.name} (${userCount} users, ${quota} spots)`; select.appendChild(option); totalManagers++; if (!firstManagerId) firstManagerId = manager.id; @@ -71,12 +65,12 @@ async function selectManager(managerId) { if (!managerId) { document.getElementById('rulesContent').style.display = 'none'; - document.getElementById('noOfficeMessage').style.display = 'block'; + document.getElementById('noManagerMessage').style.display = 'block'; return; } document.getElementById('rulesContent').style.display = 'block'; - document.getElementById('noOfficeMessage').style.display = 'none'; + document.getElementById('noManagerMessage').style.display = 'none'; await Promise.all([ loadWeeklyClosingDays(), @@ -88,7 +82,7 @@ async function selectManager(managerId) { } async function loadWeeklyClosingDays() { - const response = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); + const response = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`); if (response && response.ok) { const days = await response.json(); const weekdays = days.map(d => d.weekday); @@ -102,7 +96,7 @@ async function loadWeeklyClosingDays() { } async function loadManagerUsers() { - const response = await api.get(`/api/offices/managers/${selectedManagerId}/users`); + const response = await api.get(`/api/managers/${selectedManagerId}/users`); if (response && response.ok) { managerUsers = await response.json(); updateUserSelects(); @@ -123,7 +117,7 @@ function updateUserSelects() { } async function loadClosingDays() { - const response = await api.get(`/api/offices/managers/${selectedManagerId}/closing-days`); + const response = await api.get(`/api/managers/${selectedManagerId}/closing-days`); const container = document.getElementById('closingDaysList'); if (response && response.ok) { @@ -158,7 +152,7 @@ function formatDateRange(startDate, endDate) { } async function loadGuarantees() { - const response = await api.get(`/api/offices/managers/${selectedManagerId}/guarantees`); + const response = await api.get(`/api/managers/${selectedManagerId}/guarantees`); const container = document.getElementById('guaranteesList'); if (response && response.ok) { @@ -188,7 +182,7 @@ async function loadGuarantees() { } async function loadExclusions() { - const response = await api.get(`/api/offices/managers/${selectedManagerId}/exclusions`); + const response = await api.get(`/api/managers/${selectedManagerId}/exclusions`); const container = document.getElementById('exclusionsList'); if (response && response.ok) { @@ -220,7 +214,7 @@ async function loadExclusions() { // Delete functions async function deleteClosingDay(id) { if (!confirm('Delete this closing day?')) return; - const response = await api.delete(`/api/offices/managers/${selectedManagerId}/closing-days/${id}`); + const response = await api.delete(`/api/managers/${selectedManagerId}/closing-days/${id}`); if (response && response.ok) { await loadClosingDays(); } @@ -228,7 +222,7 @@ async function deleteClosingDay(id) { async function deleteGuarantee(id) { if (!confirm('Remove this parking guarantee?')) return; - const response = await api.delete(`/api/offices/managers/${selectedManagerId}/guarantees/${id}`); + const response = await api.delete(`/api/managers/${selectedManagerId}/guarantees/${id}`); if (response && response.ok) { await loadGuarantees(); } @@ -236,7 +230,7 @@ async function deleteGuarantee(id) { async function deleteExclusion(id) { if (!confirm('Remove this parking exclusion?')) return; - const response = await api.delete(`/api/offices/managers/${selectedManagerId}/exclusions/${id}`); + const response = await api.delete(`/api/managers/${selectedManagerId}/exclusions/${id}`); if (response && response.ok) { await loadExclusions(); } @@ -244,7 +238,7 @@ async function deleteExclusion(id) { function setupEventListeners() { // Manager selection - document.getElementById('officeSelect').addEventListener('change', (e) => { + document.getElementById('managerSelect').addEventListener('change', (e) => { selectManager(e.target.value); }); @@ -255,7 +249,7 @@ function setupEventListeners() { if (e.target.checked) { // Add weekly closing day - const response = await api.post(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`, { weekday }); + const response = await api.post(`/api/managers/${selectedManagerId}/weekly-closing-days`, { weekday }); if (!response || !response.ok) { e.target.checked = false; const error = await response.json(); @@ -263,12 +257,12 @@ function setupEventListeners() { } } else { // Remove weekly closing day - need to find the ID first - const getResponse = await api.get(`/api/offices/managers/${selectedManagerId}/weekly-closing-days`); + const getResponse = await api.get(`/api/managers/${selectedManagerId}/weekly-closing-days`); if (getResponse && getResponse.ok) { const days = await getResponse.json(); const day = days.find(d => d.weekday === weekday); if (day) { - const deleteResponse = await api.delete(`/api/offices/managers/${selectedManagerId}/weekly-closing-days/${day.id}`); + const deleteResponse = await api.delete(`/api/managers/${selectedManagerId}/weekly-closing-days/${day.id}`); if (!deleteResponse || !deleteResponse.ok) { e.target.checked = true; } @@ -320,7 +314,7 @@ function setupEventListeners() { date: document.getElementById('closingDate').value, reason: document.getElementById('closingReason').value || null }; - const response = await api.post(`/api/offices/managers/${selectedManagerId}/closing-days`, data); + const response = await api.post(`/api/managers/${selectedManagerId}/closing-days`, data); if (response && response.ok) { document.getElementById('closingDayModal').style.display = 'none'; await loadClosingDays(); @@ -337,7 +331,7 @@ function setupEventListeners() { start_date: document.getElementById('guaranteeStartDate').value || null, end_date: document.getElementById('guaranteeEndDate').value || null }; - const response = await api.post(`/api/offices/managers/${selectedManagerId}/guarantees`, data); + const response = await api.post(`/api/managers/${selectedManagerId}/guarantees`, data); if (response && response.ok) { document.getElementById('guaranteeModal').style.display = 'none'; await loadGuarantees(); @@ -354,7 +348,7 @@ function setupEventListeners() { start_date: document.getElementById('exclusionStartDate').value || null, end_date: document.getElementById('exclusionEndDate').value || null }; - const response = await api.post(`/api/offices/managers/${selectedManagerId}/exclusions`, data); + const response = await api.post(`/api/managers/${selectedManagerId}/exclusions`, data); if (response && response.ok) { document.getElementById('exclusionModal').style.display = 'none'; await loadExclusions(); diff --git a/frontend/pages/admin-users.html b/frontend/pages/admin-users.html index 0f0e931..720f183 100644 --- a/frontend/pages/admin-users.html +++ b/frontend/pages/admin-users.html @@ -54,10 +54,7 @@ Name Email Role - Office - Managed Offices - Parking Quota - Spot Prefix + Manager Actions @@ -78,9 +75,16 @@