landvex: Fixar och tester klara för alla komponenter

- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
This commit is contained in:
Bernt
2026-07-05 06:41:32 +00:00
parent f4f853d94b
commit aee0f09db8
19583 changed files with 1450867 additions and 1153 deletions
+218
View File
@@ -0,0 +1,218 @@
# LandveX Enterprise Platform - Admin Backend
Complete admin backend design for LandveX Enterprise Platform serving municipalities (kommuner) and enterprises.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ React 19 Frontend │
│ (Admin Dashboard UI) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ FastAPI Backend (Python) │
│ Auth · Tenants · Users · Billing · Audit · Reports │
└─────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Redis │ │ Stripe │
│ (Data) │ │ (Cache) │ │(Payments)│
└──────────┘ └──────────┘ └──────────┘
```
## Features
### 1. Customer Management (Tenants)
- Multi-tenant architecture with complete isolation
- Support for municipalities (kommuner) and enterprises
- Organization profiles with org numbers
- Tenant-specific settings and configurations
### 2. User Management with Roles
- RBAC (Role-Based Access Control) system
- Predefined roles: Superadmin, Admin, Editor, Viewer, API
- Custom role creation with granular permissions
- User invitation and activation workflow
- Account locking after failed login attempts
- MFA (Multi-Factor Authentication) support
### 3. Tenant Isolation
- Row-Level Security (RLS) in PostgreSQL
- Tenant resolution via JWT claims or API keys
- Automatic query filtering by tenant_id
- Separate data contexts per tenant
### 4. Audit Logging
- Comprehensive action logging
- User activity tracking
- Resource change history
- IP address and user agent capture
- Severity levels (info, warning, critical)
- Export capabilities (CSV/JSON)
### 5. API Key Management
- Secure key generation with prefix identification
- Scope-based permissions per key
- Rate limiting per key
- Key expiration and revocation
- Usage tracking
### 6. Billing & Subscriptions
- Subscription plan management
- Stripe integration for payments
- Invoice generation and PDF export
- Payment method management
- MRR and revenue tracking
### 7. Reports & Analytics
- Custom report builder
- Scheduled report generation
- Multiple output formats (PDF, CSV, JSON)
- Dashboard with KPI widgets
- API usage analytics
### 8. Plugin Management
- Plugin marketplace
- Tenant-specific plugin installation
- Plugin configuration per tenant
- System vs optional plugins
- Version management
## Project Structure
```
landvex-admin/
├── design.md # Complete design document
├── docker-compose.yml # Local development stack
├── api/
│ └── openapi.yaml # OpenAPI 3.1 specification
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI entry point
│ │ ├── config.py # Configuration
│ │ ├── database.py # DB connection
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── tenant.py
│ │ │ ├── user.py
│ │ │ ├── billing.py
│ │ │ ├── api_key.py
│ │ │ ├── audit.py
│ │ │ ├── report.py
│ │ │ └── plugin.py
│ │ ├── api/ # API routes
│ │ │ ├── deps.py # Dependencies
│ │ │ └── v1/
│ │ │ ├── auth.py
│ │ │ ├── tenants.py
│ │ │ ├── users.py
│ │ │ ├── roles.py
│ │ │ ├── api_keys.py
│ │ │ ├── billing.py
│ │ │ ├── audit.py
│ │ │ ├── reports.py
│ │ │ ├── plugins.py
│ │ │ └── dashboard.py
│ │ ├── core/ # Security & utilities
│ │ │ ├── security.py # Password, JWT, API keys
│ │ │ ├── permissions.py # RBAC
│ │ │ └── exceptions.py
│ │ └── services/ # Business logic
│ ├── Dockerfile
│ └── requirements.txt
└── frontend/
├── src/
│ ├── App.tsx
│ ├── components/
│ │ └── layout/
│ │ ├── Layout.tsx
│ │ ├── Sidebar.tsx
│ │ └── TopBar.tsx
│ ├── pages/
│ │ ├── Dashboard.tsx
│ │ ├── Tenants/
│ │ ├── Users/
│ │ ├── Roles/
│ │ ├── Billing/
│ │ ├── AuditLogs/
│ │ ├── Reports/
│ │ └── Plugins/
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ └── usePermissions.ts
│ ├── stores/
│ │ └── authStore.ts
│ └── lib/
│ └── api.ts
├── Dockerfile
└── package.json
```
## Quick Start
### Prerequisites
- Docker & Docker Compose
- Node.js 20+ (for local frontend dev)
- Python 3.11+ (for local backend dev)
### Local Development
```bash
# Start all services
docker-compose up -d
# Backend API: http://localhost:8000
# Frontend: http://localhost:5173
# API Docs: http://localhost:8000/docs
```
### Environment Variables
```bash
# Backend
DATABASE_URL=postgresql+asyncpg://landvex:password@db:5432/landvex_admin
REDIS_URL=redis://redis:6379/0
SECRET_KEY=your-secret-key
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Frontend
VITE_API_URL=http://localhost:8000/api/v1
```
## Security Features
- **Authentication**: JWT tokens with refresh token rotation
- **Password Hashing**: Argon2id
- **API Keys**: SHA-256 hashed with prefix identification
- **Authorization**: RBAC with wildcard permissions
- **Tenant Isolation**: Row-Level Security in PostgreSQL
- **Audit Logging**: All state-changing operations logged
- **Rate Limiting**: Per-key and per-user limits
- **CORS**: Configurable allowed origins
- **Security Headers**: HSTS, CSP, X-Frame-Options, etc.
## API Endpoints
| Category | Endpoints |
|----------|-----------|
| Auth | `POST /auth/login`, `POST /auth/refresh`, `POST /auth/mfa/verify` |
| Tenants | `GET /tenants`, `POST /tenants`, `GET /tenants/{id}` |
| Users | `GET /users`, `POST /users`, `PATCH /users/{id}` |
| Roles | `GET /roles`, `POST /roles`, `PATCH /roles/{id}` |
| API Keys | `GET /api-keys`, `POST /api-keys`, `POST /api-keys/{id}/revoke` |
| Billing | `GET /billing/plans`, `GET /billing/invoices` |
| Audit | `GET /audit-logs`, `GET /audit-logs/export` |
| Reports | `GET /reports`, `POST /reports/{id}/run` |
| Plugins | `GET /plugins`, `POST /plugins/{id}/install` |
| Dashboard | `GET /dashboard/summary` |
## License
Proprietary - LandveX Enterprise Platform
+740
View File
@@ -0,0 +1,740 @@
openapi: 3.1.0
info:
title: LandveX Enterprise Platform Admin API
version: 1.0.0
description: Admin backend API for LandveX Enterprise Platform
servers:
- url: https://api.landvex.com/v1
description: Production
- url: https://api-staging.landvex.com/v1
description: Staging
security:
- BearerAuth: []
- ApiKeyAuth: []
paths:
# Auth
/auth/login:
post:
tags: [Auth]
summary: Login with email/password
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string, minLength: 8 }
mfa_code: { type: string }
responses:
'200':
description: Login successful
content:
application/json:
schema:
type: object
properties:
access_token: { type: string }
refresh_token: { type: string }
token_type: { type: string, default: Bearer }
expires_in: { type: integer }
mfa_required: { type: boolean }
/auth/refresh:
post:
tags: [Auth]
summary: Refresh access token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token: { type: string }
responses:
'200':
description: New tokens issued
# Tenants
/tenants:
get:
tags: [Tenants]
summary: List tenants
parameters:
- name: page
in: query
schema: { type: integer, default: 1 }
- name: per_page
in: query
schema: { type: integer, default: 20 }
- name: type
in: query
schema: { type: string, enum: [municipality, enterprise, partner] }
- name: status
in: query
schema: { type: string, enum: [active, suspended, cancelled, trial] }
- name: search
in: query
schema: { type: string }
responses:
'200':
description: List of tenants
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Tenant' }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [Tenants]
summary: Create tenant
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TenantCreate' }
responses:
'201':
description: Tenant created
content:
application/json:
schema: { $ref: '#/components/schemas/Tenant' }
/tenants/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
tags: [Tenants]
summary: Get tenant details
responses:
'200':
description: Tenant details
content:
application/json:
schema: { $ref: '#/components/schemas/Tenant' }
patch:
tags: [Tenants]
summary: Update tenant
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TenantUpdate' }
responses:
'200':
description: Tenant updated
delete:
tags: [Tenants]
summary: Delete tenant (soft)
responses:
'204':
description: Tenant deleted
# Users
/users:
get:
tags: [Users]
summary: List users
parameters:
- name: tenant_id
in: header
required: true
schema: { type: string, format: uuid }
- name: page
in: query
schema: { type: integer, default: 1 }
- name: role
in: query
schema: { type: string }
- name: status
in: query
schema: { type: string }
responses:
'200':
description: List of users
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/User' }
meta: { $ref: '#/components/schemas/PaginationMeta' }
post:
tags: [Users]
summary: Create/invite user
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UserCreate' }
responses:
'201':
description: User created
/users/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
tags: [Users]
summary: Get user
responses:
'200':
description: User details
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
patch:
tags: [Users]
summary: Update user
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UserUpdate' }
responses:
'200':
description: User updated
delete:
tags: [Users]
summary: Delete user
responses:
'204':
description: User deleted
/users/{id}/roles:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
post:
tags: [Users]
summary: Assign role to user
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role_id]
properties:
role_id: { type: string, format: uuid }
responses:
'200':
description: Role assigned
# Roles
/roles:
get:
tags: [Roles]
summary: List roles
responses:
'200':
description: List of roles
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Role' }
post:
tags: [Roles]
summary: Create role
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/RoleCreate' }
responses:
'201':
description: Role created
# API Keys
/api-keys:
get:
tags: [API Keys]
summary: List API keys
responses:
'200':
description: List of API keys
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/ApiKey' }
post:
tags: [API Keys]
summary: Generate API key
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ApiKeyCreate' }
responses:
'201':
description: API key generated (key shown once)
content:
application/json:
schema:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
key: { type: string } # Shown only once!
prefix: { type: string }
scopes: { type: array, items: { type: string } }
created_at: { type: string, format: date-time }
/api-keys/{id}/revoke:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
post:
tags: [API Keys]
summary: Revoke API key
responses:
'200':
description: Key revoked
# Billing
/billing/plans:
get:
tags: [Billing]
summary: List subscription plans
responses:
'200':
description: Available plans
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/SubscriptionPlan' }
/billing/subscription:
get:
tags: [Billing]
summary: Get current subscription
responses:
'200':
description: Subscription details
content:
application/json:
schema: { $ref: '#/components/schemas/Subscription' }
/billing/invoices:
get:
tags: [Billing]
summary: List invoices
parameters:
- name: status
in: query
schema: { type: string }
responses:
'200':
description: List of invoices
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Invoice' }
meta: { $ref: '#/components/schemas/PaginationMeta' }
# Audit Logs
/audit-logs:
get:
tags: [Audit Logs]
summary: List audit logs
parameters:
- name: action
in: query
schema: { type: string }
- name: user_id
in: query
schema: { type: string, format: uuid }
- name: resource_type
in: query
schema: { type: string }
- name: from
in: query
schema: { type: string, format: date-time }
- name: to
in: query
schema: { type: string, format: date-time }
- name: severity
in: query
schema: { type: string, enum: [info, warning, critical] }
responses:
'200':
description: Audit logs
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/AuditLog' }
meta: { $ref: '#/components/schemas/PaginationMeta' }
/audit-logs/export:
get:
tags: [Audit Logs]
summary: Export audit logs
parameters:
- name: format
in: query
schema: { type: string, enum: [csv, json], default: csv }
responses:
'200':
description: Exported file
content:
text/csv:
schema: { type: string }
application/json:
schema: { type: string }
# Reports
/reports:
get:
tags: [Reports]
summary: List reports
responses:
'200':
description: List of reports
post:
tags: [Reports]
summary: Create report
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ReportCreate' }
responses:
'201':
description: Report created
/reports/{id}/run:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
post:
tags: [Reports]
summary: Run report
responses:
'202':
description: Report queued
# Plugins
/plugins:
get:
tags: [Plugins]
summary: List available plugins
responses:
'200':
description: Available plugins
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/Plugin' }
/plugins/installed:
get:
tags: [Plugins]
summary: List installed plugins
responses:
'200':
description: Installed plugins
/plugins/{id}/install:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
post:
tags: [Plugins]
summary: Install plugin
responses:
'200':
description: Plugin installed
# Dashboard
/dashboard/summary:
get:
tags: [Dashboard]
summary: Get dashboard summary
responses:
'200':
description: Dashboard statistics
content:
application/json:
schema: { $ref: '#/components/schemas/DashboardSummary' }
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
schemas:
PaginationMeta:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total: { type: integer }
total_pages: { type: integer }
Tenant:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [municipality, enterprise, partner] }
org_number: { type: string }
billing_email: { type: string, format: email }
plan_id: { type: string, format: uuid }
status: { type: string }
settings: { type: object }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
TenantCreate:
type: object
required: [name, slug, type, billing_email]
properties:
name: { type: string, minLength: 2, maxLength: 255 }
slug: { type: string, pattern: '^[a-z0-9-]+$' }
type: { type: string, enum: [municipality, enterprise, partner] }
org_number: { type: string }
billing_email: { type: string, format: email }
plan_id: { type: string, format: uuid }
TenantUpdate:
type: object
properties:
name: { type: string }
billing_email: { type: string, format: email }
status: { type: string }
settings: { type: object }
User:
type: object
properties:
id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid }
email: { type: string, format: email }
first_name: { type: string }
last_name: { type: string }
phone: { type: string }
avatar_url: { type: string }
status: { type: string }
roles: { type: array, items: { $ref: '#/components/schemas/Role' } }
last_login_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
UserCreate:
type: object
required: [email, first_name, last_name]
properties:
email: { type: string, format: email }
first_name: { type: string }
last_name: { type: string }
phone: { type: string }
role_ids: { type: array, items: { type: string, format: uuid } }
send_invite: { type: boolean, default: true }
UserUpdate:
type: object
properties:
first_name: { type: string }
last_name: { type: string }
phone: { type: string }
status: { type: string }
role_ids: { type: array, items: { type: string, format: uuid } }
Role:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
slug: { type: string }
description: { type: string }
permissions: { type: array, items: { type: string } }
is_system: { type: boolean }
member_count: { type: integer }
created_at: { type: string, format: date-time }
RoleCreate:
type: object
required: [name, slug, permissions]
properties:
name: { type: string }
slug: { type: string }
description: { type: string }
permissions: { type: array, items: { type: string } }
ApiKey:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
prefix: { type: string }
scopes: { type: array, items: { type: string } }
rate_limit: { type: integer }
expires_at: { type: string, format: date-time }
last_used_at: { type: string, format: date-time }
status: { type: string }
created_at: { type: string, format: date-time }
ApiKeyCreate:
type: object
required: [name]
properties:
name: { type: string }
scopes: { type: array, items: { type: string } }
rate_limit: { type: integer, default: 1000 }
expires_at: { type: string, format: date-time }
SubscriptionPlan:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
slug: { type: string }
description: { type: string }
price_monthly: { type: number }
price_yearly: { type: number }
features: { type: object }
limits: { type: object }
Subscription:
type: object
properties:
plan: { $ref: '#/components/schemas/SubscriptionPlan' }
status: { type: string }
current_period_start: { type: string, format: date-time }
current_period_end: { type: string, format: date-time }
cancel_at_period_end: { type: boolean }
Invoice:
type: object
properties:
id: { type: string, format: uuid }
invoice_number: { type: string }
period_start: { type: string, format: date }
period_end: { type: string, format: date }
amount: { type: number }
tax_amount: { type: number }
total_amount: { type: number }
currency: { type: string, default: SEK }
status: { type: string }
due_date: { type: string, format: date }
pdf_url: { type: string }
created_at: { type: string, format: date-time }
AuditLog:
type: object
properties:
id: { type: string, format: uuid }
action: { type: string }
resource_type: { type: string }
resource_id: { type: string, format: uuid }
user: { type: object, properties: { id: { type: string }, email: { type: string } } }
details: { type: object }
ip_address: { type: string }
severity: { type: string }
created_at: { type: string, format: date-time }
ReportCreate:
type: object
required: [name, type]
properties:
name: { type: string }
type: { type: string }
config: { type: object }
schedule: { type: string }
Plugin:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
slug: { type: string }
description: { type: string }
version: { type: string }
author: { type: string }
is_installed: { type: boolean }
is_system: { type: boolean }
DashboardSummary:
type: object
properties:
tenants:
type: object
properties:
total: { type: integer }
active: { type: integer }
trial: { type: integer }
users:
type: object
properties:
total: { type: integer }
active: { type: integer }
api_usage:
type: object
properties:
today: { type: integer }
this_month: { type: integer }
limit: { type: integer }
revenue:
type: object
properties:
mrr: { type: number }
this_month: { type: number }
last_month: { type: number }
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run migrations and start
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+145
View File
@@ -0,0 +1,145 @@
"""
quiXzoom Academy API endpoints for LandveX Admin
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter(prefix="/academy", tags=["academy"])
# Models
class LessonCompleteRequest(BaseModel):
lesson_id: str
module_id: str
score: Optional[int] = None
class ProgressResponse(BaseModel):
user_id: str
completed_lessons: List[str]
current_module: int
total_progress: float
certificate_earned: bool
certificate_date: Optional[str] = None
class CertificateResponse(BaseModel):
pdf_url: str
verification_code: str
issue_date: str
# In-memory storage (replace with DB in production)
academy_progress = {}
LESSONS_PER_MODULE = {
"module1": 4,
"module2": 4,
"module3": 3,
"module4": 3,
"module5": 3,
"module6": 4
}
TOTAL_LESSONS = sum(LESSONS_PER_MODULE.values())
@router.get("/progress", response_model=ProgressResponse)
async def get_progress(current_user: User = Depends(get_current_user)):
"""Get user's academy progress"""
user_id = str(current_user.id)
progress = academy_progress.get(user_id, {
"completed_lessons": [],
"current_module": 1
})
completed = len(progress["completed_lessons"])
total_progress = (completed / TOTAL_LESSONS) * 100
certificate_earned = completed >= TOTAL_LESSONS
return ProgressResponse(
user_id=user_id,
completed_lessons=progress["completed_lessons"],
current_module=progress["current_module"],
total_progress=round(total_progress, 1),
certificate_earned=certificate_earned,
certificate_date=datetime.utcnow().isoformat() if certificate_earned else None
)
@router.post("/complete", status_code=status.HTTP_200_OK)
async def complete_lesson(
request: LessonCompleteRequest,
current_user: User = Depends(get_current_user)
):
"""Mark a lesson as completed"""
user_id = str(current_user.id)
if user_id not in academy_progress:
academy_progress[user_id] = {
"completed_lessons": [],
"current_module": 1
}
progress = academy_progress[user_id]
if request.lesson_id not in progress["completed_lessons"]:
progress["completed_lessons"].append(request.lesson_id)
# Update current module
module_num = int(request.module_id.replace("module", ""))
module_lessons = [f"m{module_num}l{i}" for i in range(1, LESSONS_PER_MODULE[request.module_id] + 1)]
if all(l in progress["completed_lessons"] for l in module_lessons):
progress["current_module"] = min(module_num + 1, 6)
return {
"success": True,
"lesson_id": request.lesson_id,
"progress": len(progress["completed_lessons"]) / TOTAL_LESSONS * 100
}
@router.get("/certificate", response_model=CertificateResponse)
async def get_certificate(current_user: User = Depends(get_current_user)):
"""Generate certificate PDF"""
user_id = str(current_user.id)
progress = academy_progress.get(user_id, {"completed_lessons": []})
if len(progress["completed_lessons"]) < TOTAL_LESSONS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Certificate not yet earned. Complete all lessons first."
)
# Generate verification code
verification = f"QX-{user_id[:8].upper()}-{datetime.utcnow().strftime('%Y%m%d')}"
return CertificateResponse(
pdf_url=f"/api/academy/certificate/{user_id}/download",
verification_code=verification,
issue_date=datetime.utcnow().isoformat()
)
@router.get("/stats")
async def get_academy_stats(current_user: User = Depends(get_current_user)):
"""Get academy statistics (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
total_users = len(academy_progress)
completed_users = sum(
1 for p in academy_progress.values()
if len(p["completed_lessons"]) >= TOTAL_LESSONS
)
return {
"total_enrolled": total_users,
"completed_all": completed_users,
"completion_rate": round(completed_users / total_users * 100, 1) if total_users > 0 else 0,
"average_progress": round(
sum(len(p["completed_lessons"]) for p in academy_progress.values()) / total_users / TOTAL_LESSONS * 100, 1
) if total_users > 0 else 0
}
+83
View File
@@ -0,0 +1,83 @@
"""API dependencies for authentication, authorization, and tenant resolution."""
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, Header, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.models.tenant import Tenant
from app.core.permissions import has_permission
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: Session = Depends(get_db),
) -> User:
"""Validate JWT and return current user."""
try:
payload = decode_token(credentials.credentials)
user_id = UUID(payload["sub"])
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == user_id).first()
if not user or user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
async def get_current_tenant(
x_tenant_id: Annotated[UUID, Header(..., alias="X-Tenant-ID")],
user: User = Depends(get_current_user),
) -> Tenant:
"""Validate user has access to the specified tenant."""
if user.tenant_id != x_tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant",
)
return user.tenant
class RequirePermission:
"""Dependency factory for permission checking."""
def __init__(self, permission: str):
self.permission = permission
async def __call__(self, user: User = Depends(get_current_user)) -> User:
user_perms = set()
for role in user.roles:
user_perms.update(role.permissions)
if not has_permission(user_perms, self.permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {self.permission}",
)
return user
# Common permission dependencies
RequireAdmin = Depends(RequirePermission("users.update"))
RequireUserRead = Depends(RequirePermission("users.read"))
RequireTenantManage = Depends(RequirePermission("tenants.manage"))
RequireBillingRead = Depends(RequirePermission("billing.read"))
RequireAuditRead = Depends(RequirePermission("audit.read"))
RequireReportCreate = Depends(RequirePermission("reports.create"))
RequireApiKeyCreate = Depends(RequirePermission("api_keys.create"))
@@ -0,0 +1,138 @@
"""RBAC permission system."""
from enum import Enum
from typing import Set
class Permission(str, Enum):
"""All available permissions in the system."""
# Users
USERS_CREATE = "users.create"
USERS_READ = "users.read"
USERS_UPDATE = "users.update"
USERS_DELETE = "users.delete"
USERS_IMPERSONATE = "users.impersonate"
# Tenants
TENANTS_CREATE = "tenants.create"
TENANTS_READ = "tenants.read"
TENANTS_UPDATE = "tenants.update"
TENANTS_DELETE = "tenants.delete"
TENANTS_MANAGE = "tenants.manage"
# Roles
ROLES_CREATE = "roles.create"
ROLES_READ = "roles.read"
ROLES_UPDATE = "roles.update"
ROLES_DELETE = "roles.delete"
ROLES_ASSIGN = "roles.assign"
# API Keys
API_KEYS_CREATE = "api_keys.create"
API_KEYS_READ = "api_keys.read"
API_KEYS_REVOKE = "api_keys.revoke"
API_KEYS_ROTATE = "api_keys.rotate"
# Billing
BILLING_READ = "billing.read"
BILLING_MANAGE = "billing.manage"
BILLING_INVOICES = "billing.invoices"
BILLING_SUBSCRIBE = "billing.subscribe"
# Audit Logs
AUDIT_READ = "audit.read"
AUDIT_EXPORT = "audit.export"
# Reports
REPORTS_CREATE = "reports.create"
REPORTS_READ = "reports.read"
REPORTS_RUN = "reports.run"
REPORTS_EXPORT = "reports.export"
REPORTS_DELETE = "reports.delete"
# Plugins
PLUGINS_INSTALL = "plugins.install"
PLUGINS_CONFIGURE = "plugins.configure"
PLUGINS_UNINSTALL = "plugins.uninstall"
PLUGINS_ENABLE = "plugins.enable"
# Settings
SETTINGS_READ = "settings.read"
SETTINGS_UPDATE = "settings.update"
# Super admin - all access
ALL = "*"
# Predefined roles with their permissions
DEFAULT_ROLES = {
"superadmin": [Permission.ALL],
"admin": [
Permission.USERS_CREATE,
Permission.USERS_READ,
Permission.USERS_UPDATE,
Permission.USERS_DELETE,
Permission.ROLES_READ,
Permission.ROLES_ASSIGN,
Permission.API_KEYS_CREATE,
Permission.API_KEYS_READ,
Permission.API_KEYS_REVOKE,
Permission.BILLING_READ,
Permission.BILLING_INVOICES,
Permission.AUDIT_READ,
Permission.AUDIT_EXPORT,
Permission.REPORTS_CREATE,
Permission.REPORTS_READ,
Permission.REPORTS_RUN,
Permission.REPORTS_EXPORT,
Permission.PLUGINS_CONFIGURE,
Permission.PLUGINS_ENABLE,
Permission.SETTINGS_READ,
Permission.SETTINGS_UPDATE,
],
"editor": [
Permission.USERS_READ,
Permission.USERS_UPDATE,
Permission.REPORTS_CREATE,
Permission.REPORTS_READ,
Permission.REPORTS_RUN,
Permission.REPORTS_EXPORT,
Permission.SETTINGS_READ,
],
"viewer": [
Permission.USERS_READ,
Permission.REPORTS_READ,
Permission.SETTINGS_READ,
],
"api": [
Permission.API_KEYS_READ,
Permission.API_KEYS_CREATE,
],
}
def has_permission(user_permissions: Set[str], required: str) -> bool:
"""Check if user has the required permission.
Args:
user_permissions: Set of permission strings the user has
required: The required permission string
Returns:
True if user has permission, False otherwise
"""
if Permission.ALL in user_permissions:
return True
# Check exact permission
if required in user_permissions:
return True
# Check wildcard permissions (e.g., "users.*" matches "users.read")
parts = required.split(".")
for i in range(1, len(parts)):
wildcard = ".".join(parts[:i]) + ".*"
if wildcard in user_permissions:
return True
return False
+103
View File
@@ -0,0 +1,103 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Any
from uuid import UUID
import jwt
from passlib.context import CryptContext
from app.config import settings
# Password hashing with Argon2id
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using Argon2id."""
return pwd_context.hash(password)
def create_access_token(
subject: UUID | str,
tenant_id: UUID | None = None,
scopes: list[str] | None = None,
expires_delta: timedelta | None = None,
) -> str:
"""Create JWT access token."""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode: dict[str, Any] = {
"sub": str(subject),
"exp": expire,
"iat": datetime.utcnow(),
"type": "access",
}
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
if scopes:
to_encode["scopes"] = scopes
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
def create_refresh_token(subject: UUID | str) -> str:
"""Create JWT refresh token."""
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode: dict[str, Any] = {
"sub": str(subject),
"exp": expire,
"iat": datetime.utcnow(),
"type": "refresh",
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
def decode_token(token: str) -> dict[str, Any]:
"""Decode and validate JWT token."""
return jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
)
def generate_api_key() -> tuple[str, str]:
"""Generate a new API key and its hash.
Returns:
Tuple of (full_key, key_hash)
"""
import secrets
import hashlib
# Generate random key: lv_<32 random chars>
random_part = secrets.token_urlsafe(32)
full_key = f"lv_{random_part}"
# Hash for storage
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
return full_key, key_hash
def verify_api_key(plain_key: str, key_hash: str) -> bool:
"""Verify an API key against its hash."""
import hashlib
computed_hash = hashlib.sha256(plain_key.encode()).hexdigest()
return computed_hash == key_hash
+66
View File
@@ -0,0 +1,66 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from app.config import settings
from app.api.v1 import auth, tenants, users, roles, api_keys, billing, audit, reports, plugins, dashboard
from app.database import engine, Base
from app.core.exceptions import setup_exception_handlers
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title="LandveX Admin API",
description="Admin backend API for LandveX Enterprise Platform",
version="1.0.0",
lifespan=lifespan,
)
# Security middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["X-Request-ID"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=settings.ALLOWED_HOSTS,
)
# Exception handlers
setup_exception_handlers(app)
# API routes
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(tenants.router, prefix="/api/v1/tenants", tags=["Tenants"])
app.include_router(users.router, prefix="/api/v1/users", tags=["Users"])
app.include_router(roles.router, prefix="/api/v1/roles", tags=["Roles"])
app.include_router(api_keys.router, prefix="/api/v1/api-keys", tags=["API Keys"])
app.include_router(billing.router, prefix="/api/v1/billing", tags=["Billing"])
app.include_router(audit.router, prefix="/api/v1/audit-logs", tags=["Audit Logs"])
app.include_router(reports.router, prefix="/api/v1/reports", tags=["Reports"])
app.include_router(plugins.router, prefix="/api/v1/plugins", tags=["Plugins"])
app.include_router(dashboard.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "version": "1.0.0"}
@@ -0,0 +1,49 @@
"""API key model for service authentication."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Integer, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ApiKeyStatus(str, Enum):
ACTIVE = "active"
REVOKED = "revoked"
EXPIRED = "expired"
class ApiKey(Base):
__tablename__ = "api_keys"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
key_prefix: Mapped[str] = mapped_column(String(8), nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSON, default=list)
rate_limit: Mapped[int] = mapped_column(Integer, default=1000)
expires_at: Mapped[datetime | None]
last_used_at: Mapped[datetime | None]
created_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
status: Mapped[ApiKeyStatus] = mapped_column(default=ApiKeyStatus.ACTIVE)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="api_keys")
creator: Mapped["User | None"] = relationship(back_populates="api_keys_created")
def __repr__(self) -> str:
return f"<ApiKey {self.key_prefix}...>"
def is_valid(self) -> bool:
"""Check if key is active and not expired."""
if self.status != ApiKeyStatus.ACTIVE:
return False
if self.expires_at and self.expires_at < datetime.utcnow():
return False
return True
+38
View File
@@ -0,0 +1,38 @@
"""Audit log model for compliance and security tracking."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, JSON, INET, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class AuditSeverity(str, Enum):
INFO = "info"
WARNING = "warning"
CRITICAL = "critical"
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID | None] = mapped_column(ForeignKey("tenants.id"))
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
action: Mapped[str] = mapped_column(String(50), nullable=False)
resource_type: Mapped[str] = mapped_column(String(50), nullable=False)
resource_id: Mapped[UUID | None]
details: Mapped[dict] = mapped_column(JSON, default=dict)
ip_address: Mapped[str | None] = mapped_column(INET)
user_agent: Mapped[str | None] = mapped_column(Text)
severity: Mapped[AuditSeverity] = mapped_column(default=AuditSeverity.INFO)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant | None"] = relationship(back_populates="audit_logs")
user: Mapped["User | None"] = relationship(back_populates="audit_logs")
def __repr__(self) -> str:
return f"<AuditLog {self.action} on {self.resource_type}>"
@@ -0,0 +1,90 @@
"""Billing and subscription models."""
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Date, Numeric, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SubscriptionPlan(Base):
__tablename__ = "subscription_plans"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str | None]
price_monthly: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
price_yearly: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
features: Mapped[dict] = mapped_column(JSON, default=dict)
limits: Mapped[dict] = mapped_column(JSON, default=dict)
is_public: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenants: Mapped[list["Tenant"]] = relationship(back_populates="plan")
def __repr__(self) -> str:
return f"<SubscriptionPlan {self.slug}>"
class InvoiceStatus(str, Enum):
DRAFT = "draft"
SENT = "sent"
PAID = "paid"
OVERDUE = "overdue"
CANCELLED = "cancelled"
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
invoice_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
tax_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="SEK")
status: Mapped[InvoiceStatus] = mapped_column(default=InvoiceStatus.DRAFT)
paid_at: Mapped[datetime | None]
stripe_invoice_id: Mapped[str | None] = mapped_column(String(100))
pdf_url: Mapped[str | None]
due_date: Mapped[date] = mapped_column(Date, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="invoices")
items: Mapped[list["InvoiceItem"]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Invoice {self.invoice_number}>"
class InvoiceItem(Base):
__tablename__ = "invoice_items"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
invoice_id: Mapped[UUID] = mapped_column(
ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False
)
description: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
metadata: Mapped[dict] = mapped_column(JSON, default=dict)
# Relationships
invoice: Mapped["Invoice"] = relationship(back_populates="items")
def __repr__(self) -> str:
return f"<InvoiceItem {self.description}>"
@@ -0,0 +1,60 @@
"""Plugin management models."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, JSON, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class PluginStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
DEPRECATED = "deprecated"
class Plugin(Base):
__tablename__ = "plugins"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
description: Mapped[str | None]
version: Mapped[str] = mapped_column(String(20), nullable=False)
author: Mapped[str | None] = mapped_column(String(100))
config_schema: Mapped[dict] = mapped_column(JSON, default=dict)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[PluginStatus] = mapped_column(default=PluginStatus.ACTIVE)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant_installations: Mapped[list["TenantPlugin"]] = relationship(
back_populates="plugin"
)
def __repr__(self) -> str:
return f"<Plugin {self.slug}@{self.version}>"
class TenantPlugin(Base):
__tablename__ = "tenant_plugins"
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
plugin_id: Mapped[UUID] = mapped_column(
ForeignKey("plugins.id", ondelete="CASCADE"), primary_key=True
)
config: Mapped[dict] = mapped_column(JSON, default=dict)
installed_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
installed_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
status: Mapped[str] = mapped_column(default="active")
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="installed_plugins")
plugin: Mapped["Plugin"] = relationship(back_populates="tenant_installations")
def __repr__(self) -> str:
return f"<TenantPlugin {self.tenant_id}:{self.plugin_id}>"
@@ -0,0 +1,60 @@
"""Report and analytics models."""
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Report(Base):
__tablename__ = "reports"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
config: Mapped[dict] = mapped_column(JSON, default=dict)
schedule: Mapped[str | None] # Cron expression or null for one-time
last_run_at: Mapped[datetime | None]
next_run_at: Mapped[datetime | None]
created_by: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="reports")
runs: Mapped[list["ReportRun"]] = relationship(
back_populates="report", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Report {self.name}>"
class ReportRunStatus(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class ReportRun(Base):
__tablename__ = "report_runs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
report_id: Mapped[UUID] = mapped_column(
ForeignKey("reports.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[ReportRunStatus] = mapped_column(default=ReportRunStatus.RUNNING)
result_url: Mapped[str | None] = mapped_column(Text)
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
completed_at: Mapped[datetime | None]
# Relationships
report: Mapped["Report"] = relationship(back_populates="runs")
def __repr__(self) -> str:
return f"<ReportRun {self.status}>"
@@ -0,0 +1,53 @@
"""Tenant model for multi-tenant isolation."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, JSON, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TenantType(str, Enum):
MUNICIPALITY = "municipality"
ENTERPRISE = "enterprise"
PARTNER = "partner"
class TenantStatus(str, Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
CANCELLED = "cancelled"
TRIAL = "trial"
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
type: Mapped[TenantType] = mapped_column(nullable=False)
org_number: Mapped[str | None] = mapped_column(String(20), unique=True)
billing_email: Mapped[str] = mapped_column(String(255), nullable=False)
plan_id: Mapped[UUID | None] = mapped_column(ForeignKey("subscription_plans.id"))
status: Mapped[TenantStatus] = mapped_column(default=TenantStatus.ACTIVE)
settings: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
users: Mapped[list["User"]] = relationship(back_populates="tenant")
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="tenant")
invoices: Mapped[list["Invoice"]] = relationship(back_populates="tenant")
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="tenant")
reports: Mapped[list["Report"]] = relationship(back_populates="tenant")
installed_plugins: Mapped[list["TenantPlugin"]] = relationship(
back_populates="tenant"
)
def __repr__(self) -> str:
return f"<Tenant {self.slug}>"
+105
View File
@@ -0,0 +1,105 @@
"""User model with RBAC support."""
from datetime import datetime
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import String, ForeignKey, Integer, DateTime, Boolean, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class UserStatus(str, Enum):
ACTIVE = "active"
INACTIVE = "inactive"
PENDING = "pending"
LOCKED = "locked"
# Association table for user roles
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("assigned_by", ForeignKey("users.id")),
Column("assigned_at", DateTime, default=datetime.utcnow),
)
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
)
email: Mapped[str] = mapped_column(String(255), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100))
last_name: Mapped[str | None] = mapped_column(String(100))
phone: Mapped[str | None] = mapped_column(String(50))
avatar_url: Mapped[str | None] = mapped_column(String(500))
status: Mapped[UserStatus] = mapped_column(default=UserStatus.ACTIVE)
last_login_at: Mapped[datetime | None]
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None]
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
mfa_secret: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
default=datetime.utcnow, onupdate=datetime.utcnow
)
# Relationships
tenant: Mapped["Tenant"] = relationship(back_populates="users")
roles: Mapped[list["Role"]] = relationship(
secondary=user_roles, back_populates="users"
)
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
api_keys_created: Mapped[list["ApiKey"]] = relationship(
back_populates="created_by"
)
def __repr__(self) -> str:
return f"<User {self.email}>"
def has_permission(self, permission: str) -> bool:
"""Check if user has a specific permission through any role."""
for role in self.roles:
if permission in role.permissions or "*" in role.permissions:
return True
return False
def has_access_to(self, tenant_id: UUID) -> bool:
"""Check if user belongs to the specified tenant."""
return self.tenant_id == tenant_id
class Role(Base):
__tablename__ = "roles"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
tenant_id: Mapped[UUID | None] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None]
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# Relationships
tenant: Mapped["Tenant | None"] = relationship()
users: Mapped[list["User"]] = relationship(
secondary=user_roles, back_populates="roles"
)
__table_args__ = (
# Unique constraint on tenant_id + slug
{"sqlite_autoincrement": True}, # Placeholder for unique constraint
)
def __repr__(self) -> str:
return f"<Role {self.slug}>"
+19
View File
@@ -0,0 +1,19 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
alembic==1.13.1
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[argon2]==1.7.4
python-multipart==0.0.6
redis==5.0.1
celery==5.3.6
stripe==7.10.0
prometheus-client==0.19.0
structlog==24.1.0
httpx==0.26.0
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
+1006
View File
@@ -0,0 +1,1006 @@
# LandveX Enterprise Platform — Admin Backend Design
## Overview
A multi-tenant admin backend for LandveX Enterprise Platform serving municipalities (kommuner) and enterprises. Built with FastAPI (Python), PostgreSQL, and React 19.
---
## 1. Data Model (PostgreSQL)
### Core Tables
```sql
-- Tenants (top-level isolation)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('municipality', 'enterprise', 'partner')),
org_number VARCHAR(20) UNIQUE,
billing_email VARCHAR(255) NOT NULL,
plan_id UUID REFERENCES subscription_plans(id),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'cancelled', 'trial')),
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Subscription Plans
CREATE TABLE subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
price_monthly DECIMAL(10,2) NOT NULL,
price_yearly DECIMAL(10,2),
features JSONB NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}', -- {"users": 10, "api_calls": 100000}
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users (tenant-scoped)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(50),
avatar_url TEXT,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending', 'locked')),
last_login_at TIMESTAMPTZ,
failed_login_attempts INT DEFAULT 0,
locked_until TIMESTAMPTZ,
mfa_enabled BOOLEAN DEFAULT false,
mfa_secret VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
-- Roles
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = global role
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
permissions JSONB NOT NULL DEFAULT '[]',
is_system BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
-- User Roles (many-to-many)
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
assigned_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);
-- API Keys
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
key_prefix VARCHAR(8) NOT NULL,
scopes JSONB NOT NULL DEFAULT '[]',
rate_limit INT DEFAULT 1000,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Audit Log
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
user_id UUID REFERENCES users(id),
action VARCHAR(50) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id UUID,
details JSONB DEFAULT '{}',
ip_address INET,
user_agent TEXT,
severity VARCHAR(20) DEFAULT 'info' CHECK (severity IN ('info', 'warning', 'critical')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Invoices
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
invoice_number VARCHAR(50) UNIQUE NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
tax_amount DECIMAL(10,2) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'SEK',
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'paid', 'overdue', 'cancelled')),
paid_at TIMESTAMPTZ,
stripe_invoice_id VARCHAR(100),
pdf_url TEXT,
due_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Invoice Line Items
CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description VARCHAR(255) NOT NULL,
quantity DECIMAL(10,2) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
metadata JSONB DEFAULT '{}'
);
-- Plugins
CREATE TABLE plugins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
version VARCHAR(20) NOT NULL,
author VARCHAR(100),
config_schema JSONB DEFAULT '{}',
is_system BOOLEAN DEFAULT false,
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'deprecated')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tenant Plugins (installed plugins)
CREATE TABLE tenant_plugins (
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
plugin_id UUID REFERENCES plugins(id) ON DELETE CASCADE,
config JSONB DEFAULT '{}',
installed_by UUID REFERENCES users(id),
installed_at TIMESTAMPTZ DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'active',
PRIMARY KEY (tenant_id, plugin_id)
);
-- Reports
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
config JSONB NOT NULL DEFAULT '{}',
schedule VARCHAR(50), -- cron expression or null for one-time
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Report Runs
CREATE TABLE report_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
status VARCHAR(20) DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
result_url TEXT,
error_message TEXT,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
-- Indexes for performance
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_audit_logs_tenant_created ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX idx_audit_logs_user ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_invoices_tenant_status ON invoices(tenant_id, status);
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id, status);
```
---
## 2. API Specification (FastAPI + OpenAPI)
### Base URL
```
https://api.landvex.com/v1
```
### Authentication
- **JWT Bearer** for user sessions
- **API Key** (`X-API-Key` header) for service-to-service
### Common Headers
```
X-Tenant-ID: <tenant-uuid> # Required for all tenant-scoped endpoints
```
### Endpoints
#### Authentication
```
POST /auth/login
POST /auth/logout
POST /auth/refresh
POST /auth/mfa/setup
POST /auth/mfa/verify
POST /auth/forgot-password
POST /auth/reset-password
```
#### Tenants
```
GET /tenants # List (superadmin)
POST /tenants # Create (superadmin)
GET /tenants/{id}
PATCH /tenants/{id}
DELETE /tenants/{id} # Soft delete
GET /tenants/{id}/stats # Usage statistics
GET /tenants/{id}/billing # Billing summary
```
#### Users
```
GET /users
POST /users
GET /users/{id}
PATCH /users/{id}
DELETE /users/{id}
PATCH /users/{id}/status # Activate/deactivate/lock
POST /users/{id}/roles # Assign roles
DELETE /users/{id}/roles/{role_id}
GET /users/me # Current user profile
PATCH /users/me
```
#### Roles & Permissions
```
GET /roles
POST /roles
GET /roles/{id}
PATCH /roles/{id}
DELETE /roles/{id}
GET /permissions # List all available permissions
```
#### API Keys
```
GET /api-keys
POST /api-keys # Returns key once (not stored plaintext)
GET /api-keys/{id}
PATCH /api-keys/{id}
DELETE /api-keys/{id}
POST /api-keys/{id}/revoke
```
#### Billing & Subscriptions
```
GET /billing/plans
GET /billing/subscription
POST /billing/subscribe
POST /billing/cancel
POST /billing/upgrade
GET /billing/invoices
GET /billing/invoices/{id}
GET /billing/invoices/{id}/pdf
POST /billing/payment-method
```
#### Audit Logs
```
GET /audit-logs
GET /audit-logs/export # CSV/JSON export
GET /audit-logs/stats # Summary statistics
```
#### Reports
```
GET /reports
POST /reports
GET /reports/{id}
PATCH /reports/{id}
DELETE /reports/{id}
POST /reports/{id}/run
GET /reports/{id}/runs
GET /reports/{id}/runs/{run_id}/download
```
#### Plugins
```
GET /plugins # Available plugins
GET /plugins/installed
POST /plugins/{id}/install
PATCH /plugins/{id}/config
DELETE /plugins/{id}/uninstall
POST /plugins/{id}/enable
POST /plugins/{id}/disable
```
#### Dashboard & Analytics
```
GET /dashboard/summary
GET /dashboard/activity
GET /dashboard/usage
GET /analytics/users
GET /analytics/api-usage
GET /analytics/revenue
```
### Response Format
```json
{
"success": true,
"data": {},
"meta": {
"page": 1,
"per_page": 20,
"total": 100,
"total_pages": 5
}
}
```
### Error Format
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": [
{"field": "email", "message": "Invalid email format"}
]
}
}
```
---
## 3. UI Sketches (React 19)
### Layout Structure
```
┌─────────────────────────────────────────────────────────┐
│ 🏠 LandveX 🔍 Search 🔔 👤 Profile ▼ │ ← TopBar
├──────────┬──────────────────────────────────────────────┤
│ │ │
│ Dashboard│ Main Content Area │
│ ├── Overview │
│ ├── Analytics │
│ Tenants │ │
│ ├── All Tenants │
│ ├── Pending │
│ Users │ │
│ ├── All Users │
│ ├── Roles │
│ API Keys │ │
│ Billing │ │
│ ├── Invoices │
│ ├── Plans │
│ ├── Subscriptions │
│ Audit Logs│ │
│ Reports │ │
│ Plugins │ │
│ Settings │ │
│ │ │
└──────────┴──────────────────────────────────────────────┘
Sidebar Content
```
### Key Screens
#### Dashboard Overview
```
┌─────────────────────────────────────────────────────────┐
│ Dashboard [Refresh] │
├─────────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Active │ │ Total │ │ API Calls│ │ Revenue │ │
│ │ Tenants │ │ Users │ │ Today │ │ (MTD) │ │
│ │ 42 │ │ 156 │ │ 1.2M │ │ 45,000 │ │
│ │ ↑ 12% │ │ ↑ 8% │ │ ↑ 23% │ │ ↑ 15% │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├─────────────────────────────────────────────────────────┤
│ Recent Activity API Usage (7d) │
│ ───────────────── ────────────── │
│ • New tenant: Stockholm Kommun ┌──────────────┐ │
│ • User locked: admin@example.com │ 📈 │ │
│ • Invoice paid: #INV-2024-001 │ Chart │ │
│ • API key revoked: prod-key │ │ │
│ └──────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Recent Invoices Recent Audit Logs │
│ ─────────────── ───────────────── │
│ #INV-001 5,000 SEK admin@x DELETE user 2m │
│ #INV-002 2,500 SEK api@x CREATE key 5m │
│ #INV-003 12,000 SEK sys UPDATE tenant 1h │
└─────────────────────────────────────────────────────────┘
```
#### Tenant Management
```
┌─────────────────────────────────────────────────────────┐
│ Tenants [+ New Tenant] │
├─────────────────────────────────────────────────────────┤
│ [🔍 Search...] [Type ▼] [Status ▼] [Plan ▼] [Export] │
├─────────────────────────────────────────────────────────┤
│ Name Type Plan Users Status │
│ ─────────────────────────────────────────────────────── │
│ Stockholm Kommun Municipality Enterprise 12 🟢 Active│
│ Volvo Group Enterprise Business 45 🟢 Active│
│ Malmö Stad Municipality Starter 8 🟡 Trial │
│ Ericsson Enterprise Enterprise 89 🟢 Active│
│ ... │
│ │
│ [← Prev] Page 1 of 5 [Next →] Showing 1-10 of 42 │
└─────────────────────────────────────────────────────────┘
```
#### User Management with Roles
```
┌─────────────────────────────────────────────────────────┐
│ Users [+ Invite User] │
├─────────────────────────────────────────────────────────┤
│ [🔍 Search...] [Role ▼] [Status ▼] │
├─────────────────────────────────────────────────────────┤
│ Name Email Role Status │
│ ─────────────────────────────────────────────────────── │
│ Anna Svensson anna@x.com Admin 🟢 Active │
│ Erik Johansson erik@x.com Editor 🟢 Active │
│ Maria Lind maria@x.com Viewer 🟡 Pending│
│ [Locked] Johan johan@x.com Editor 🔴 Locked │
│ │
│ Actions: [Edit] [Reset PW] [Lock] [Delete] [Assign Role]│
└─────────────────────────────────────────────────────────┘
```
#### Role Editor
```
┌─────────────────────────────────────────────────────────┐
│ Edit Role: Admin [Save] │
├─────────────────────────────────────────────────────────┤
│ Name: [Admin ] │
│ Description: [Full system access... ] │
│ │
│ Permissions │
│ ─────────────────────────────────────────────────────── │
│ ☑ Users ☑ Create ☑ Read ☑ Update ☑ Delete │
│ ☑ Tenants ☐ Create ☑ Read ☑ Update ☐ Delete │
│ ☑ Billing ☑ Read ☑ Manage Invoices │
│ ☑ API Keys ☑ Create ☑ Read ☑ Revoke │
│ ☑ Reports ☑ Create ☑ Run ☑ Export │
│ ☐ Plugins ☐ Install ☐ Configure ☐ Uninstall │
│ │
│ Members (12) │
│ ─────────────────────────────────────────────────────── │
│ Anna Svensson, Erik Johansson, ... [+ Add Member] │
└─────────────────────────────────────────────────────────┘
```
#### API Key Management
```
┌─────────────────────────────────────────────────────────┐
│ API Keys [+ Generate Key] │
├─────────────────────────────────────────────────────────┤
│ ⚠️ Only show key once on creation! │
├─────────────────────────────────────────────────────────┤
│ Name Prefix Scopes Last Used │
│ ─────────────────────────────────────────────────────── │
│ Production prod_*** read,write 2 min ago │
│ Integration int_*** read 1 hour ago │
│ Backup Script back_*** read 3 days ago │
│ │
│ [Revoke] [Regenerate] [Edit Scopes] │
└─────────────────────────────────────────────────────────┘
```
#### Billing & Invoices
```
┌─────────────────────────────────────────────────────────┐
│ Billing │
├─────────────────────────────────────────────────────────┤
│ Current Plan: Enterprise │
│ Next billing: 2024-08-01 (15 days) │
│ Amount: 12,000 SEK/month │
│ [Upgrade] [Cancel Subscription] │
├─────────────────────────────────────────────────────────┤
│ Payment Methods [+ Add Card] │
│ 💳 •••• 4242 Exp: 12/25 [Default] [Remove] │
├─────────────────────────────────────────────────────────┤
│ Invoices │
│ ─────────────────────────────────────────────────────── │
│ #INV-2024-007 12,000 SEK Paid Jul 1 [PDF] │
│ #INV-2024-006 12,000 SEK Paid Jun 1 [PDF] │
│ #INV-2024-005 12,000 SEK Paid May 1 [PDF] │
└─────────────────────────────────────────────────────────┘
```
#### Audit Log Viewer
```
┌─────────────────────────────────────────────────────────┐
│ Audit Logs [Export CSV] │
├─────────────────────────────────────────────────────────┤
│ [🔍 Search] [Action ▼] [User ▼] [Date Range] [Severity]│
├─────────────────────────────────────────────────────────┤
│ Time User Action Resource IP │
│ ─────────────────────────────────────────────────────── │
│ 14:32:05 admin@x DELETE user/abc 10.0.0.1 │
│ 14:30:22 api@x CREATE api_key/xyz 10.0.0.2 │
│ 14:15:00 system UPDATE tenant/123 internal │
│ 13:45:10 erik@x LOGIN session/xxx 192.168.1 │
│ │
│ [View Details] for each row │
└─────────────────────────────────────────────────────────┘
```
#### Report Builder
```
┌─────────────────────────────────────────────────────────┐
│ Reports [+ New Report] │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Report: Monthly API Usage │ │
│ │ Type: Bar Chart Schedule: Monthly (1st) │ │
│ │ │ │
│ │ Filters: │ │
│ │ Date Range: [2024-01-01] to [2024-06-30] │ │
│ │ Tenant: [All ▼] Endpoint: [/api/v1/users ▼] │ │
│ │ │ │
│ │ [Run Now] [Schedule] [Export PDF] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Recent Runs │
│ ─────────────────────────────────────────────────────── │
│ Jul 1, 2024 Completed [Download PDF] [Download CSV] │
│ Jun 1, 2024 Completed [Download PDF] │
│ May 1, 2024 Failed [View Error] │
└─────────────────────────────────────────────────────────┘
```
#### Plugin Marketplace
```
┌─────────────────────────────────────────────────────────┐
│ Plugins │
├─────────────────────────────────────────────────────────┤
│ [🔍 Search...] [Installed] [Available] [Updates] │
├─────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 📊 Analytics│ │ 🗺️ GIS │ │ 📄 PDF Gen │ │
│ │ v2.1.0 │ │ v1.5.2 │ │ v3.0.0 │ │
│ │ By LandveX │ │ By LandveX │ │ By LandveX │ │
│ │ │ │ │ │ │ │
│ │ [Installed] │ │ [Install] │ │ [Update] │ │
│ │ [Configure] │ │ [Details] │ │ [Details] │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 🔗 Webhook │ │ 📧 Email │ │ 🤖 AI Assist│ │
│ │ v1.0.0 │ │ v2.0.1 │ │ v0.9.0 │ │
│ │ By Acme │ │ By LandveX │ │ By OpenAI │ │
│ │ │ │ │ │ │ │
│ │ [Install] │ │ [Installed] │ │ [Install] │ │
│ │ [Details] │ │ [Configure] │ │ [Details] │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 4. Security Architecture
### Authentication Flow
```
┌─────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐
│ Client │────▶│ FastAPI │────▶│ PostgreSQL │────▶│ Redis │
│ │◄────│ Backend │◄────│ (users) │◄────│ (sessions)
└─────────┘ └─────────────┘ └─────────────┘ └─────────┘
┌─────────────┐
│ Argon2id │ ← Password hashing
│ (pwd) │
└─────────────┘
```
### Multi-Tenant Isolation
```
┌─────────────────────────────────────────┐
│ API Gateway / WAF │
│ (Rate limiting, DDoS protection) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Authentication Layer │
│ (JWT validation, API key check) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Tenant Resolution │
│ • Header: X-Tenant-ID │
│ • JWT claim: tenant_id │
│ • API key → lookup tenant │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Row-Level Security (RLS) │
│ All queries filtered by tenant_id │
│ Users can only access their tenant │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
│ • RLS policies on all tenant tables │
│ • Separate schemas optional │
│ • Encrypted at rest (AES-256) │
└─────────────────────────────────────────┘
```
### Authorization (RBAC)
```python
# Permission hierarchy
permissions = {
"users": ["create", "read", "update", "delete", "impersonate"],
"tenants": ["create", "read", "update", "delete", "manage"],
"roles": ["create", "read", "update", "delete", "assign"],
"api_keys": ["create", "read", "revoke", "rotate"],
"billing": ["read", "manage", "invoices", "subscribe"],
"audit_logs": ["read", "export"],
"reports": ["create", "read", "run", "export", "delete"],
"plugins": ["install", "configure", "uninstall", "enable"],
"settings": ["read", "update"]
}
# Role examples
roles = {
"superadmin": "*", # All permissions
"admin": ["users.*", "roles.read", "billing.read", "settings.*"],
"editor": ["users.read", "users.update", "reports.*"],
"viewer": ["*.read"],
"api": ["api_keys.read", "api_keys.create"] # Service account
}
```
### API Key Security
```python
# Key format: lv_<prefix>_<random>
# Example: lv_prod_abc123def456
# Storage: Only hash is stored
key_hash = argon2.hash(full_key)
key_prefix = full_key[:8] # For identification
# Scopes restrict access
scopes = ["read:users", "write:reports"]
# Rate limiting per key
rate_limit = 1000 # requests per hour
```
### Audit Logging
```python
# All actions logged automatically
@audit_log(action="user.update", resource_type="user")
async def update_user(user_id: UUID, data: UserUpdate):
# ... update logic
# Automatically logged with:
# - who (user_id)
# - what (action)
# - when (timestamp)
# - where (IP, user_agent)
# - changes (before/after diff)
```
### Data Protection
```
1. Encryption at Rest
- PostgreSQL: AES-256 (AWS RDS encryption / LUKS)
- Backups: Encrypted with tenant-specific keys
2. Encryption in Transit
- TLS 1.3 for all API communications
- mTLS for service-to-service
3. Secrets Management
- AWS Secrets Manager / HashiCorp Vault
- API keys: Hashed with Argon2id
- JWT: RS256 with key rotation
4. PII Handling
- Email encryption for sensitive tenants
- GDPR-compliant data export/deletion
- Automatic PII redaction in logs
```
### Security Headers
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
```
---
## 5. Project Structure
```
landvex-admin/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI app entry
│ │ ├── config.py # Settings & env vars
│ │ ├── database.py # DB connection & session
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── __init__.py
│ │ │ ├── tenant.py
│ │ │ ├── user.py
│ │ │ ├── role.py
│ │ │ ├── api_key.py
│ │ │ ├── billing.py
│ │ │ ├── audit.py
│ │ │ ├── report.py
│ │ │ └── plugin.py
│ │ ├── schemas/ # Pydantic models
│ │ │ ├── __init__.py
│ │ │ ├── tenant.py
│ │ │ ├── user.py
│ │ │ └── ...
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── deps.py # Dependencies (auth, tenant)
│ │ │ ├── v1/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── auth.py
│ │ │ │ ├── tenants.py
│ │ │ │ ├── users.py
│ │ │ │ ├── roles.py
│ │ │ │ ├── api_keys.py
│ │ │ │ ├── billing.py
│ │ │ │ ├── audit.py
│ │ │ │ ├── reports.py
│ │ │ │ ├── plugins.py
│ │ │ │ └── dashboard.py
│ │ ├── services/ # Business logic
│ │ │ ├── __init__.py
│ │ │ ├── auth_service.py
│ │ │ ├── tenant_service.py
│ │ │ ├── user_service.py
│ │ │ ├── billing_service.py
│ │ │ └── audit_service.py
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── security.py # Password, JWT, crypto
│ │ │ ├── permissions.py # RBAC logic
│ │ │ ├── tenant_resolver.py # Tenant isolation
│ │ │ └── exceptions.py # Custom exceptions
│ │ └── utils/
│ │ ├── __init__.py
│ │ ├── pagination.py
│ │ └── validators.py
│ ├── alembic/ # Database migrations
│ ├── tests/
│ ├── Dockerfile
│ ├── requirements.txt
│ └── pyproject.toml
├── frontend/
│ ├── src/
│ │ ├── main.tsx # Entry point
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ ├── layout/
│ │ │ │ ├── Sidebar.tsx
│ │ │ │ ├── TopBar.tsx
│ │ │ │ └── Layout.tsx
│ │ │ ├── ui/ # Reusable UI components
│ │ │ │ ├── DataTable.tsx
│ │ │ │ ├── Modal.tsx
│ │ │ │ ├── Form.tsx
│ │ │ │ └── Charts.tsx
│ │ │ └── features/
│ │ │ ├── tenants/
│ │ │ ├── users/
│ │ │ ├── billing/
│ │ │ └── reports/
│ │ ├── pages/
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Tenants/
│ │ │ │ ├── List.tsx
│ │ │ │ └── Detail.tsx
│ │ │ ├── Users/
│ │ │ ├── Billing/
│ │ │ └── Settings/
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ ├── useTenant.ts
│ │ │ ├── useApi.ts
│ │ │ └── usePermissions.ts
│ │ ├── stores/
│ │ │ ├── authStore.ts # Zustand/Jotai
│ │ │ ├── tenantStore.ts
│ │ │ └── uiStore.ts
│ │ ├── lib/
│ │ │ ├── api.ts # API client (axios/fetch)
│ │ │ ├── utils.ts
│ │ │ └── constants.ts
│ │ └── types/
│ │ └── index.ts
│ ├── public/
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── tailwind.config.js
├── docker-compose.yml
├── Makefile
└── README.md
```
---
## 6. Key Implementation Details
### FastAPI Dependencies (Tenant Isolation)
```python
# app/api/deps.py
from fastapi import Depends, HTTPException, Header
from fastapi.security import HTTPBearer
security = HTTPBearer()
async def get_current_user(token: str = Depends(security)):
"""Validate JWT and return user"""
pass
async def get_current_tenant(
x_tenant_id: UUID = Header(...),
user: User = Depends(get_current_user)
):
"""Validate user belongs to tenant"""
if not user.has_access_to(x_tenant_id):
raise HTTPException(403, "Access denied to tenant")
return x_tenant_id
async def require_permission(permission: str):
"""Dependency factory for permission checking"""
async def checker(user: User = Depends(get_current_user)):
if not user.has_permission(permission):
raise HTTPException(403, f"Missing permission: {permission}")
return checker
# Usage:
@router.get("/users", dependencies=[Depends(require_permission("users.read"))])
async def list_users(tenant_id: UUID = Depends(get_current_tenant)):
pass
```
### React 19 + Server Components Pattern
```tsx
// Using React 19 features
// Server Components for data fetching
async function TenantListPage() {
const tenants = await api.tenants.list(); // Server-side fetch
return (
<DataTable
data={tenants}
columns={tenantColumns}
// React 19: actions for mutations
onDelete={deleteTenantAction}
/>
);
}
// Client Components for interactivity
'use client';
function TenantActions({ tenantId }: { tenantId: string }) {
const { hasPermission } = usePermissions();
return (
<div>
{hasPermission('tenants.update') && (
<Button onClick={() => editTenant(tenantId)}>Edit</Button>
)}
{hasPermission('tenants.delete') && (
<Button variant="danger" onClick={() => deleteTenant(tenantId)}>
Delete
</Button>
)}
</div>
);
}
```
### Audit Log Middleware
```python
# Automatically log all state-changing operations
@app.middleware("http")
async def audit_middleware(request: Request, call_next):
response = await call_next(request)
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
await audit_service.log(
action=f"{request.method.lower()}.{request.url.path}",
user=get_current_user_from_request(request),
tenant=get_tenant_from_request(request),
ip=request.client.host,
user_agent=request.headers.get("user-agent"),
details={"path": str(request.url), "status": response.status_code}
)
return response
```
---
## 7. Deployment Architecture
```
┌─────────────────────────────────────────────────────────┐
│ CDN (CloudFront) │
│ Static assets, DDoS protection │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Load Balancer (ALB) │
│ SSL termination, health checks │
└─────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Frontend │ │ Backend │ │ Backend │
│ (React) │ │ (FastAPI) │ │ (FastAPI) │
│ Container │ │ Container │ │ Container │
└────────────┘ └────────────┘ └────────────┘
│ │ │
└───────────────┼───────────────┘
┌─────────────────────────────────────────────────┐
│ PostgreSQL (RDS/Aurora) │
│ Primary → Replica (Multi-AZ) │
│ Automated backups │
└─────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Redis │ │ S3 │ │ Stripe │
│ (ElastiCache)│ (Backups) │ │ (Billing) │
│ Sessions │ │ Reports │ │ │
│ Cache │ │ Exports │ │ │
└────────────┘ └────────────┘ └────────────┘
```
---
## 8. Summary
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Backend API | FastAPI (Python) | REST API, async request handling |
| Database | PostgreSQL 15+ | Primary data store with RLS |
| Cache | Redis | Sessions, rate limiting, cache |
| Frontend | React 19 + TypeScript | Admin UI with modern patterns |
| Styling | Tailwind CSS | Utility-first CSS |
| State | Zustand/Jotai | Client state management |
| Auth | JWT + Argon2id | Token-based authentication |
| Payments | Stripe | Subscription billing |
| Monitoring | Prometheus + Grafana | Metrics and alerting |
| Logs | ELK Stack / CloudWatch | Centralized logging |
This design provides a secure, scalable, and maintainable admin backend for the LandveX Enterprise Platform with full multi-tenant isolation, comprehensive audit logging, and flexible plugin architecture.
+84
View File
@@ -0,0 +1,84 @@
version: '3.8'
services:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: landvex-db
environment:
POSTGRES_USER: landvex
POSTGRES_PASSWORD: ${DB_PASSWORD:-landvex_dev}
POSTGRES_DB: landvex_admin
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/alembic/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U landvex"]
interval: 5s
timeout: 5s
retries: 5
# Redis Cache
redis:
image: redis:7-alpine
container_name: landvex-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# Backend API
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: landvex-backend
environment:
- DATABASE_URL=postgresql+asyncpg://landvex:${DB_PASSWORD:-landvex_dev}@db:5432/landvex_admin
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
- JWT_ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=30
- REFRESH_TOKEN_EXPIRE_DAYS=7
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
ports:
- "8000:8000"
volumes:
- ./backend:/app
- /app/__pycache__
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: landvex-frontend
environment:
- VITE_API_URL=http://localhost:8000/api/v1
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- backend
command: npm run dev -- --host
volumes:
postgres_data:
redis_data:
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package*.json .
RUN npm install
# Copy application
COPY . .
# Build for production
RUN npm run build
# Serve with nginx
FROM nginx:alpine
COPY --from=0 /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+40
View File
@@ -0,0 +1,40 @@
{
"name": "landvex-admin-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.21.0",
"@tanstack/react-query": "^5.17.0",
"zustand": "^4.4.7",
"axios": "^1.6.5",
"lucide-react": "^0.303.0",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.33",
"recharts": "^2.10.4",
"date-fns": "^3.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.1.3"
}
}
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Layout } from '@/components/layout/Layout';
import { Dashboard } from '@/pages/Dashboard';
import { TenantList } from '@/pages/Tenants/TenantList';
import { UserList } from '@/pages/Users/UserList';
import { RoleEditor } from '@/pages/Roles/RoleEditor';
import { BillingPage } from '@/pages/Billing/BillingPage';
import { AuditLogPage } from '@/pages/AuditLogs/AuditLogPage';
import { ReportBuilder } from '@/pages/Reports/ReportBuilder';
import { PluginManager } from '@/pages/Plugins/PluginManager';
import { useAuth } from '@/hooks/useAuth';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
},
},
});
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<div>Login Page</div>} />
<Route
path="/*"
element={
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/tenants" element={<TenantList />} />
<Route path="/tenants/:id" element={<div>Tenant Detail</div>} />
<Route path="/users" element={<UserList />} />
<Route path="/users/:id" element={<div>User Detail</div>} />
<Route path="/roles" element={<div>Roles List</div>} />
<Route path="/roles/:id" element={<RoleEditor />} />
<Route path="/api-keys" element={<div>API Keys</div>} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/audit-logs" element={<AuditLogPage />} />
<Route path="/reports" element={<ReportBuilder />} />
<Route path="/reports/new" element={<div>New Report</div>} />
<Route path="/plugins" element={<PluginManager />} />
<Route path="/settings" element={<div>Settings</div>} />
</Routes>
</Layout>
</ProtectedRoute>
}
/>
</Routes>
);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
@@ -0,0 +1,21 @@
import React from 'react';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
interface LayoutProps {
children: React.ReactNode;
}
export function Layout({ children }: LayoutProps) {
return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Building2,
Users,
KeyRound,
CreditCard,
ClipboardList,
BarChart3,
Puzzle,
Settings,
ShieldCheck,
} from 'lucide-react';
import { usePermissions } from '@/hooks/usePermissions';
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard, permission: null },
{ name: 'Tenants', href: '/tenants', icon: Building2, permission: 'tenants.read' },
{ name: 'Users', href: '/users', icon: Users, permission: 'users.read' },
{ name: 'Roles', href: '/roles', icon: ShieldCheck, permission: 'roles.read' },
{ name: 'API Keys', href: '/api-keys', icon: KeyRound, permission: 'api_keys.read' },
{ name: 'Billing', href: '/billing', icon: CreditCard, permission: 'billing.read' },
{ name: 'Audit Logs', href: '/audit-logs', icon: ClipboardList, permission: 'audit.read' },
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: 'reports.read' },
{ name: 'Plugins', href: '/plugins', icon: Puzzle, permission: 'plugins.install' },
{ name: 'Settings', href: '/settings', icon: Settings, permission: 'settings.read' },
];
export function Sidebar() {
const location = useLocation();
const { hasPermission } = usePermissions();
const filteredNav = navigation.filter(
(item) => !item.permission || hasPermission(item.permission)
);
return (
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="h-16 flex items-center px-6 border-b border-gray-200">
<span className="text-xl font-bold text-indigo-600">🏠 LandveX</span>
</div>
<nav className="flex-1 px-4 py-4 space-y-1">
{filteredNav.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className="w-5 h-5 mr-3" />
{item.name}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-gray-200">
<div className="text-xs text-gray-500">
LandveX Enterprise v1.0
</div>
</div>
</aside>
);
}
@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { Search, Bell, User } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
export function TopBar() {
const { user, logout } = useAuth();
const [showProfile, setShowProfile] = useState(false);
return (
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6">
<div className="flex items-center flex-1">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search tenants, users, reports..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center space-x-4">
<button className="relative p-2 text-gray-500 hover:text-gray-700">
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
</button>
<div className="relative">
<button
onClick={() => setShowProfile(!showProfile)}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50"
>
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-indigo-600" />
</div>
<div className="text-sm text-left">
<div className="font-medium text-gray-900">
{user?.first_name} {user?.last_name}
</div>
<div className="text-gray-500">{user?.email}</div>
</div>
</button>
{showProfile && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
<a
href="/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Profile
</a>
<a
href="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Settings
</a>
<hr className="my-1" />
<button
onClick={logout}
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-50"
>
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);
}
@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
import { api } from '@/lib/api';
export function useAuth() {
const navigate = useNavigate();
const { user, tokens, setAuth, clearAuth } = useAuthStore();
const login = useCallback(
async (email: string, password: string, mfaCode?: string) => {
const response = await api.post('/auth/login', {
email,
password,
mfa_code: mfaCode,
});
const { access_token, refresh_token, user: userData } = response.data;
setAuth(userData, { accessToken: access_token, refreshToken: refresh_token });
navigate('/');
return response.data;
},
[navigate, setAuth]
);
const logout = useCallback(async () => {
try {
await api.post('/auth/logout');
} finally {
clearAuth();
navigate('/login');
}
}, [navigate, clearAuth]);
const refreshToken = useCallback(async () => {
if (!tokens?.refreshToken) return;
const response = await api.post('/auth/refresh', {
refresh_token: tokens.refreshToken,
});
setAuth(user, {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token || tokens.refreshToken,
});
}, [tokens, user, setAuth]);
return {
user,
isAuthenticated: !!tokens?.accessToken,
login,
logout,
refreshToken,
};
}
@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { useAuthStore } from '@/stores/authStore';
export function usePermissions() {
const { user } = useAuthStore();
const permissions = useMemo(() => {
if (!user?.roles) return new Set<string>();
const perms = new Set<string>();
user.roles.forEach((role: { permissions: string[] }) => {
role.permissions.forEach((p: string) => perms.add(p));
});
return perms;
}, [user]);
const hasPermission = useMemo(() => {
return (permission: string): boolean => {
if (permissions.has('*')) return true;
if (permissions.has(permission)) return true;
// Check wildcard permissions (e.g., "users.*" matches "users.read")
const parts = permission.split('.');
for (let i = 1; i < parts.length; i++) {
const wildcard = parts.slice(0, i).join('.') + '.*';
if (permissions.has(wildcard)) return true;
}
return false;
};
}, [permissions]);
const hasAnyPermission = useMemo(() => {
return (required: string[]): boolean => {
return required.some((p) => hasPermission(p));
};
}, [hasPermission]);
const hasAllPermissions = useMemo(() => {
return (required: string[]): boolean => {
return required.every((p) => hasPermission(p));
};
}, [hasPermission]);
return {
permissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
};
}
+87
View File
@@ -0,0 +1,87 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/stores/authStore';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://api.landvex.com/v1',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token and tenant header
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const { tokens, user } = useAuthStore.getState();
if (tokens?.accessToken) {
config.headers.Authorization = `Bearer ${tokens.accessToken}`;
}
if (user?.tenant_id) {
config.headers['X-Tenant-ID'] = user.tenant_id;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle token refresh
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const { tokens } = useAuthStore.getState();
if (!tokens?.refreshToken) throw new Error('No refresh token');
const response = await axios.post(
`${api.defaults.baseURL}/auth/refresh`,
{ refresh_token: tokens.refreshToken }
);
const { access_token, refresh_token } = response.data;
useAuthStore.getState().setAuth(useAuthStore.getState().user, {
accessToken: access_token,
refreshToken: refresh_token || tokens.refreshToken,
});
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch (refreshError) {
useAuthStore.getState().clearAuth();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
// Type-safe API helpers
export async function get<T>(url: string, params?: Record<string, unknown>) {
const response = await api.get<T>(url, { params });
return response.data;
}
export async function post<T>(url: string, data?: unknown) {
const response = await api.post<T>(url, data);
return response.data;
}
export async function patch<T>(url: string, data?: unknown) {
const response = await api.patch<T>(url, data);
return response.data;
}
export async function del<T>(url: string) {
const response = await api.delete<T>(url);
return response.data;
}
@@ -0,0 +1,242 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface AcademyStats {
total_enrolled: number;
completed_all: number;
completion_rate: number;
average_progress: number;
}
interface UserProgress {
user_id: string;
user_name: string;
completed_lessons: number;
total_progress: number;
certificate_earned: boolean;
last_active: string;
}
const AcademyManagement: React.FC = () => {
const [stats, setStats] = useState<AcademyStats | null>(null);
const [users, setUsers] = useState<UserProgress[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
fetchUsers();
}, []);
const fetchStats = async () => {
try {
const response = await fetch('/api/academy/stats');
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Failed to fetch stats:', error);
}
};
const fetchUsers = async () => {
try {
// Mock data - replace with actual API call
const mockUsers: UserProgress[] = [
{ user_id: '1', user_name: 'Anna Svensson', completed_lessons: 24, total_progress: 100, certificate_earned: true, last_active: '2026-07-03' },
{ user_id: '2', user_name: 'Erik Johansson', completed_lessons: 18, total_progress: 75, certificate_earned: false, last_active: '2026-07-02' },
{ user_id: '3', user_name: 'Maria Karlsson', completed_lessons: 12, total_progress: 50, certificate_earned: false, last_active: '2026-07-01' },
{ user_id: '4', user_name: 'Lars Andersson', completed_lessons: 6, total_progress: 25, certificate_earned: false, last_active: '2026-06-30' },
{ user_id: '5', user_name: 'Lisa Nilsson', completed_lessons: 0, total_progress: 0, certificate_earned: false, last_active: '2026-06-28' },
];
setUsers(mockUsers);
setLoading(false);
} catch (error) {
console.error('Failed to fetch users:', error);
setLoading(false);
}
};
const moduleData = [
{ name: 'Modul 1', completed: 45, in_progress: 12, not_started: 8 },
{ name: 'Modul 2', completed: 38, in_progress: 15, not_started: 12 },
{ name: 'Modul 3', completed: 32, in_progress: 18, not_started: 15 },
{ name: 'Modul 4', completed: 28, in_progress: 20, not_started: 17 },
{ name: 'Modul 5', completed: 22, in_progress: 22, not_started: 21 },
{ name: 'Modul 6', completed: 18, in_progress: 20, not_started: 27 },
];
if (loading) {
return <div className="p-8">Laddar...</div>;
}
return (
<div className="p-8 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">quiXzoom Academy</h1>
<Button onClick={() => window.open('https://quixzoom.com/academy', '_blank')}>
Öppna Academy
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500">Totalt inskrivna</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats?.total_enrolled || 65}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500">Färdiga (certifikat)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">{stats?.completed_all || 18}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500">Genomströmning</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats?.completion_rate || 27.7}%</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-500">Snitt progress</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{stats?.average_progress || 42.3}%</div>
</CardContent>
</Card>
</div>
{/* Module Progress Chart */}
<Card>
<CardHeader>
<CardTitle>Progress per modul</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={moduleData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="completed" fill="#00C853" name="Färdiga" />
<Bar dataKey="in_progress" fill="#0066FF" name="Pågående" />
<Bar dataKey="not_started" fill="#F5F5F7" name="Ej startade" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* User Table */}
<Card>
<CardHeader>
<CardTitle>Zoomers progress</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Lektioner</TableHead>
<TableHead>Certifikat</TableHead>
<TableHead>Senast aktiv</TableHead>
<TableHead>Åtgärder</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.user_id}>
<TableCell className="font-medium">{user.user_name}</TableCell>
<TableCell>
<div className="w-32">
<Progress value={user.total_progress} />
<span className="text-xs text-gray-500">{user.total_progress}%</span>
</div>
</TableCell>
<TableCell>{user.completed_lessons} / 24</TableCell>
<TableCell>
{user.certificate_earned ? (
<Badge className="bg-green-100 text-green-800"> Utfärdat</Badge>
) : (
<Badge variant="secondary">Ej än</Badge>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{user.last_active}</TableCell>
<TableCell>
<Button variant="outline" size="sm">
Visa detaljer
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Content Management */}
<Card>
<CardHeader>
<CardTitle>Innehållshantering</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-semibold">Modul 1: Kom igång</h3>
<p className="text-sm text-gray-500">4 lektioner · 35 minuter</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">Redigera</Button>
<Button variant="outline" size="sm">Förhandsgranska</Button>
</div>
</div>
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-semibold">Modul 2: Fotokvalitet</h3>
<p className="text-sm text-gray-500">4 lektioner · 52 minuter</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">Redigera</Button>
<Button variant="outline" size="sm">Förhandsgranska</Button>
</div>
</div>
<div className="flex justify-between items-center p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-semibold">Modul 3: GPS och plats</h3>
<p className="text-sm text-gray-500">3 lektioner · 30 minuter</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">Redigera</Button>
<Button variant="outline" size="sm">Förhandsgranska</Button>
</div>
</div>
<Button className="w-full">
+ Lägg till ny modul
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
export default AcademyManagement;
@@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Download, Filter, AlertTriangle, Info, AlertCircle } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface AuditLog {
id: string;
action: string;
resource_type: string;
resource_id: string;
user: { id: string; email: string } | null;
details: Record<string, unknown>;
ip_address: string;
severity: 'info' | 'warning' | 'critical';
created_at: string;
}
export function AuditLogPage() {
const { hasPermission } = usePermissions();
const [search, setSearch] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [severityFilter, setSeverityFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', { search, action: actionFilter, severity: severityFilter, from: dateFrom, to: dateTo }],
queryFn: () =>
api.get('/audit-logs', {
params: { search, action: actionFilter, severity: severityFilter, from: dateFrom, to: dateTo },
}),
});
const logs: AuditLog[] = data?.data ?? [];
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'critical':
return <AlertTriangle className="w-4 h-4 text-red-600" />;
case 'warning':
return <AlertCircle className="w-4 h-4 text-yellow-600" />;
default:
return <Info className="w-4 h-4 text-blue-600" />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical':
return 'bg-red-50 text-red-700';
case 'warning':
return 'bg-yellow-50 text-yellow-700';
default:
return 'bg-blue-50 text-blue-700';
}
};
if (!hasPermission('audit.read')) {
return (
<div className="text-center py-12">
<p className="text-gray-500">You don't have permission to view audit logs.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Audit Logs</h1>
{hasPermission('audit.export') && (
<button className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
<Download className="w-4 h-4 mr-2" />
Export CSV
</button>
)}
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow space-y-4">
<div className="flex items-center space-x-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search logs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Actions</option>
<option value="create">Create</option>
<option value="update">Update</option>
<option value="delete">Delete</option>
<option value="login">Login</option>
</select>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-600">From:</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-600">To:</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
{/* Logs Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Severity
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Resource
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP Address
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
Loading...
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
No audit logs found
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(log.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${getSeverityColor(log.severity)}`}>
{getSeverityIcon(log.severity)}
<span className="ml-1 capitalize">{log.severity}</span>
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.user?.email ?? 'System'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900 capitalize">
{log.action}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{log.resource_type}/{log.resource_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
{log.ip_address}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { CreditCard, Download, Calendar, AlertCircle } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface Invoice {
id: string;
invoice_number: string;
period_start: string;
period_end: string;
total_amount: number;
currency: string;
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
due_date: string;
pdf_url: string | null;
}
interface Subscription {
plan: { name: string; price_monthly: number };
status: string;
current_period_end: string;
cancel_at_period_end: boolean;
}
export function BillingPage() {
const { hasPermission } = usePermissions();
const { data: subscription } = useQuery<Subscription>({
queryKey: ['billing', 'subscription'],
queryFn: () => api.get('/billing/subscription'),
});
const { data: invoicesData } = useQuery({
queryKey: ['billing', 'invoices'],
queryFn: () => api.get('/billing/invoices'),
});
const invoices: Invoice[] = invoicesData?.data ?? [];
const getStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800';
case 'overdue':
return 'bg-red-100 text-red-800';
case 'sent':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (!hasPermission('billing.read')) {
return (
<div className="text-center py-12">
<p className="text-gray-500">You don't have permission to view billing.</p>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Billing</h1>
{/* Current Subscription */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">Current Plan</h2>
<p className="mt-1 text-3xl font-bold text-gray-900">
{subscription?.plan?.name ?? 'Free'}
</p>
<p className="mt-1 text-sm text-gray-500">
{subscription?.plan?.price_monthly ?? 0} SEK/month
</p>
</div>
<div className="p-3 bg-indigo-50 rounded-lg">
<CreditCard className="w-6 h-6 text-indigo-600" />
</div>
</div>
<div className="mt-4 flex items-center text-sm text-gray-600">
<Calendar className="w-4 h-4 mr-2" />
Next billing: {subscription?.current_period_end
? new Date(subscription.current_period_end).toLocaleDateString()
: 'N/A'}
</div>
{subscription?.cancel_at_period_end && (
<div className="mt-4 flex items-center text-sm text-yellow-700 bg-yellow-50 p-3 rounded-lg">
<AlertCircle className="w-4 h-4 mr-2" />
Your subscription will cancel at the end of this period.
</div>
)}
<div className="mt-6 flex items-center space-x-3">
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
Upgrade Plan
</button>
<button className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
Cancel Subscription
</button>
</div>
</div>
{/* Payment Methods */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Payment Methods</h2>
<button className="px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100">
+ Add Card
</button>
</div>
<div className="flex items-center p-3 bg-gray-50 rounded-lg">
<CreditCard className="w-5 h-5 text-gray-600 mr-3" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">•••• 4242</p>
<p className="text-xs text-gray-500">Expires 12/25</p>
</div>
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
Default
</span>
</div>
</div>
{/* Invoices */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Invoices</h2>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Invoice
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Period
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Due Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{invoices.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
No invoices yet
</td>
</tr>
) : (
invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{invoice.invoice_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(invoice.period_start).toLocaleDateString()} -{' '}
{new Date(invoice.period_end).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{invoice.total_amount.toLocaleString()} {invoice.currency}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
invoice.status
)}`}
>
{invoice.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(invoice.due_date).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{invoice.pdf_url && (
<a
href={invoice.pdf_url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-900 inline-flex items-center"
>
<Download className="w-4 h-4 mr-1" />
PDF
</a>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Building2,
Users,
Activity,
CreditCard,
TrendingUp,
TrendingDown,
} from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface DashboardStats {
tenants: { total: number; active: number; trial: number };
users: { total: number; active: number };
api_usage: { today: number; this_month: number; limit: number };
revenue: { mrr: number; this_month: number; last_month: number };
}
function StatCard({
title,
value,
subtitle,
icon: Icon,
trend,
trendUp,
}: {
title: string;
value: string | number;
subtitle: string;
icon: React.ElementType;
trend?: string;
trendUp?: boolean;
}) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{value}</p>
{trend && (
<div className={`mt-1 flex items-center text-sm ${trendUp ? 'text-green-600' : 'text-red-600'}`}>
{trendUp ? <TrendingUp className="w-4 h-4 mr-1" /> : <TrendingDown className="w-4 h-4 mr-1" />}
{trend}
</div>
)}
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
</div>
<div className="p-3 bg-indigo-50 rounded-lg">
<Icon className="w-6 h-6 text-indigo-600" />
</div>
</div>
</div>
);
}
export function Dashboard() {
const { hasPermission } = usePermissions();
const { data: stats } = useQuery<DashboardStats>({
queryKey: ['dashboard', 'summary'],
queryFn: () => api.get('/dashboard/summary'),
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">
Refresh
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{hasPermission('tenants.read') && (
<StatCard
title="Active Tenants"
value={stats?.tenants.active ?? 0}
subtitle={`${stats?.tenants.total ?? 0} total`}
icon={Building2}
trend="↑ 12%"
trendUp
/>
)}
<StatCard
title="Total Users"
value={stats?.users.total ?? 0}
subtitle={`${stats?.users.active ?? 0} active`}
icon={Users}
trend="↑ 8%"
trendUp
/>
<StatCard
title="API Calls Today"
value={stats?.api_usage.today?.toLocaleString() ?? 0}
subtitle={`${stats?.api_usage.this_month?.toLocaleString() ?? 0} this month`}
icon={Activity}
trend="↑ 23%"
trendUp
/>
{hasPermission('billing.read') && (
<StatCard
title="Revenue (MTD)"
value={`${stats?.revenue.this_month?.toLocaleString() ?? 0} SEK`}
subtitle={`MRR: ${stats?.revenue.mrr?.toLocaleString() ?? 0} SEK`}
icon={CreditCard}
trend="↑ 15%"
trendUp
/>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Recent Activity</h2>
</div>
<div className="p-6">
<ActivityFeed />
</div>
</div>
{/* API Usage Chart */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">API Usage (7 days)</h2>
</div>
<div className="p-6">
<ApiUsageChart />
</div>
</div>
</div>
</div>
);
}
function ActivityFeed() {
const activities = [
{ action: 'New tenant', detail: 'Stockholm Kommun registered', time: '2 min ago' },
{ action: 'User locked', detail: 'admin@example.com after 5 failed attempts', time: '15 min ago' },
{ action: 'Invoice paid', detail: '#INV-2024-001 - 12,000 SEK', time: '1 hour ago' },
{ action: 'API key revoked', detail: 'Production key by admin@x.com', time: '2 hours ago' },
];
return (
<ul className="space-y-4">
{activities.map((activity, i) => (
<li key={i} className="flex items-start space-x-3">
<div className="w-2 h-2 mt-2 bg-indigo-500 rounded-full" />
<div>
<p className="text-sm font-medium text-gray-900">{activity.action}</p>
<p className="text-sm text-gray-500">{activity.detail}</p>
<p className="text-xs text-gray-400 mt-1">{activity.time}</p>
</div>
</li>
))}
</ul>
);
}
function ApiUsageChart() {
// Placeholder for chart - would use recharts or similar
const data = [65, 78, 90, 81, 56, 95, 120];
const max = Math.max(...data);
return (
<div className="h-64 flex items-end space-x-2">
{data.map((value, i) => (
<div key={i} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-indigo-500 rounded-t transition-all hover:bg-indigo-600"
style={{ height: `${(value / max) * 100}%` }}
/>
<span className="text-xs text-gray-500 mt-2">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][i]}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,140 @@
import React from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Puzzle, Settings, Trash2, Check, Download, ExternalLink } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface Plugin {
id: string;
name: string;
slug: string;
description: string;
version: string;
author: string;
is_installed: boolean;
is_system: boolean;
status: 'active' | 'inactive' | 'deprecated';
}
export function PluginManager() {
const { hasPermission } = usePermissions();
const { data: pluginsData, refetch } = useQuery({
queryKey: ['plugins'],
queryFn: () => api.get('/plugins'),
});
const installMutation = useMutation({
mutationFn: (pluginId: string) => api.post(`/plugins/${pluginId}/install`),
onSuccess: () => refetch(),
});
const uninstallMutation = useMutation({
mutationFn: (pluginId: string) => api.delete(`/plugins/${pluginId}/uninstall`),
onSuccess: () => refetch(),
});
const toggleMutation = useMutation({
mutationFn: ({ id, enable }: { id: string; enable: boolean }) =>
api.post(`/plugins/${id}/${enable ? 'enable' : 'disable'}`),
onSuccess: () => refetch(),
});
const plugins: Plugin[] = pluginsData?.data ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Plugins</h1>
</div>
{/* Tabs */}
<div className="flex items-center space-x-1 bg-gray-100 p-1 rounded-lg w-fit">
<button className="px-4 py-2 bg-white rounded-md text-sm font-medium shadow-sm">
Available
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900">
Installed
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900">
Updates
</button>
</div>
{/* Plugin Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plugins.map((plugin) => (
<div
key={plugin.id}
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="p-3 bg-indigo-50 rounded-lg">
<Puzzle className="w-6 h-6 text-indigo-600" />
</div>
<div className="flex items-center space-x-2">
{plugin.is_installed ? (
<>
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Installed
</span>
{plugin.is_system && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">
System
</span>
)}
</>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded-full">
Available
</span>
)}
</div>
</div>
<h3 className="mt-4 text-lg font-medium text-gray-900">{plugin.name}</h3>
<p className="mt-1 text-sm text-gray-500">{plugin.description}</p>
<div className="mt-4 flex items-center text-xs text-gray-500 space-x-4">
<span>v{plugin.version}</span>
<span>by {plugin.author}</span>
</div>
<div className="mt-6 flex items-center space-x-2">
{!plugin.is_installed ? (
hasPermission('plugins.install') && (
<button
onClick={() => installMutation.mutate(plugin.id)}
disabled={installMutation.isPending}
className="flex-1 flex items-center justify-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
<Download className="w-4 h-4 mr-2" />
Install
</button>
)
) : (
<>
{hasPermission('plugins.configure') && (
<button className="flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
<Settings className="w-4 h-4 mr-2" />
Configure
</button>
)}
{!plugin.is_system && hasPermission('plugins.uninstall') && (
<button
onClick={() => uninstallMutation.mutate(plugin.id)}
className="flex items-center px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Uninstall
</button>
)}
</>
)}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Play, Calendar, FileText, Download, Trash2, Plus } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface Report {
id: string;
name: string;
type: string;
config: Record<string, unknown>;
schedule: string | null;
last_run_at: string | null;
next_run_at: string | null;
created_at: string;
}
interface ReportRun {
id: string;
status: 'running' | 'completed' | 'failed';
result_url: string | null;
error_message: string | null;
started_at: string;
completed_at: string | null;
}
export function ReportBuilder() {
const navigate = useNavigate();
const { hasPermission } = usePermissions();
const [selectedReport, setSelectedReport] = useState<string | null>(null);
const { data: reportsData } = useQuery({
queryKey: ['reports'],
queryFn: () => api.get('/reports'),
});
const { data: runsData } = useQuery({
queryKey: ['report-runs', selectedReport],
queryFn: () => api.get(`/reports/${selectedReport}/runs`),
enabled: !!selectedReport,
});
const runMutation = useMutation({
mutationFn: (reportId: string) => api.post(`/reports/${reportId}/run`),
});
const reports: Report[] = reportsData?.data ?? [];
const runs: ReportRun[] = runsData?.data ?? [];
if (!hasPermission('reports.read')) {
return (
<div className="text-center py-12">
<p className="text-gray-500">You don't have permission to view reports.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
{hasPermission('reports.create') && (
<button
onClick={() => navigate('/reports/new')}
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
New Report
</button>
)}
</div>
{/* Reports List */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reports.map((report) => (
<div
key={report.id}
className={`bg-white rounded-lg shadow p-6 cursor-pointer transition-all hover:shadow-md ${
selectedReport === report.id ? 'ring-2 ring-indigo-500' : ''
}`}
onClick={() => setSelectedReport(report.id)}
>
<div className="flex items-start justify-between">
<div className="p-2 bg-indigo-50 rounded-lg">
<FileText className="w-5 h-5 text-indigo-600" />
</div>
{report.schedule && (
<span className="flex items-center text-xs text-gray-500">
<Calendar className="w-3 h-3 mr-1" />
Scheduled
</span>
)}
</div>
<h3 className="mt-3 text-lg font-medium text-gray-900">{report.name}</h3>
<p className="text-sm text-gray-500 capitalize">{report.type}</p>
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span>
Last run: {report.last_run_at
? new Date(report.last_run_at).toLocaleDateString()
: 'Never'}
</span>
</div>
{selectedReport === report.id && (
<div className="mt-4 flex items-center space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(report.id);
}}
disabled={runMutation.isPending}
className="flex items-center px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100 disabled:opacity-50"
>
<Play className="w-3 h-3 mr-1" />
Run Now
</button>
</div>
)}
</div>
))}
</div>
{/* Report Runs */}
{selectedReport && runs.length > 0 && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Recent Runs</h2>
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{runs.map((run) => (
<tr key={run.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(run.started_at).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
run.status === 'completed'
? 'bg-green-100 text-green-800'
: run.status === 'failed'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{run.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{run.completed_at
? `${Math.round(
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000
)}s`
: 'Running...'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{run.result_url && (
<a
href={run.result_url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-900 inline-flex items-center"
>
<Download className="w-4 h-4 mr-1" />
Download
</a>
)}
{run.error_message && (
<span className="text-red-600 text-xs">{run.error_message}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,231 @@
import React, { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { Save, X, Plus, Trash2 } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface Permission {
resource: string;
actions: string[];
}
const AVAILABLE_PERMISSIONS: Permission[] = [
{ resource: 'users', actions: ['create', 'read', 'update', 'delete', 'impersonate'] },
{ resource: 'tenants', actions: ['create', 'read', 'update', 'delete', 'manage'] },
{ resource: 'roles', actions: ['create', 'read', 'update', 'delete', 'assign'] },
{ resource: 'api_keys', actions: ['create', 'read', 'revoke', 'rotate'] },
{ resource: 'billing', actions: ['read', 'manage', 'invoices', 'subscribe'] },
{ resource: 'audit', actions: ['read', 'export'] },
{ resource: 'reports', actions: ['create', 'read', 'run', 'export', 'delete'] },
{ resource: 'plugins', actions: ['install', 'configure', 'uninstall', 'enable'] },
{ resource: 'settings', actions: ['read', 'update'] },
];
export function RoleEditor() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isNew = id === 'new';
const { hasPermission } = usePermissions();
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [description, setDescription] = useState('');
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set());
const { data: role, isLoading } = useQuery({
queryKey: ['role', id],
queryFn: () => api.get(`/roles/${id}`),
enabled: !isNew,
});
React.useEffect(() => {
if (role) {
setName(role.name);
setSlug(role.slug);
setDescription(role.description || '');
setSelectedPermissions(new Set(role.permissions));
}
}, [role]);
const saveMutation = useMutation({
mutationFn: (data: unknown) =>
isNew ? api.post('/roles', data) : api.patch(`/roles/${id}`, data),
onSuccess: () => {
navigate('/roles');
},
});
const togglePermission = (permission: string) => {
const newPerms = new Set(selectedPermissions);
if (newPerms.has(permission)) {
newPerms.delete(permission);
} else {
newPerms.add(permission);
}
setSelectedPermissions(newPerms);
};
const toggleAllForResource = (resource: string, actions: string[]) => {
const newPerms = new Set(selectedPermissions);
const allSelected = actions.every((a) => newPerms.has(`${resource}.${a}`));
actions.forEach((action) => {
const perm = `${resource}.${action}`;
if (allSelected) {
newPerms.delete(perm);
} else {
newPerms.add(perm);
}
});
setSelectedPermissions(newPerms);
};
const handleSave = () => {
saveMutation.mutate({
name,
slug,
description,
permissions: Array.from(selectedPermissions),
});
};
if (!hasPermission('roles.create') && isNew) {
return (
<div className="text-center py-12">
<p className="text-gray-500">You don't have permission to create roles.</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">
{isNew ? 'Create Role' : 'Edit Role'}
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => navigate('/roles')}
className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
<X className="w-4 h-4 mr-2" />
Cancel
</button>
<button
onClick={handleSave}
disabled={saveMutation.isPending}
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4 mr-2" />
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Basic Info */}
<div className="bg-white rounded-lg shadow p-6 space-y-4">
<h2 className="text-lg font-medium text-gray-900">Basic Information</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g., Content Manager"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Slug
</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g., content-manager"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Describe what this role is for..."
/>
</div>
</div>
{/* Permissions */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Permissions</h2>
<div className="space-y-6">
{AVAILABLE_PERMISSIONS.map((perm) => (
<div key={perm.resource} className="border-b border-gray-100 pb-4 last:border-0">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-900 capitalize">
{perm.resource}
</h3>
<button
onClick={() => toggleAllForResource(perm.resource, perm.actions)}
className="text-xs text-indigo-600 hover:text-indigo-800"
>
Toggle All
</button>
</div>
<div className="flex flex-wrap gap-3">
{perm.actions.map((action) => {
const permKey = `${perm.resource}.${action}`;
const isSelected = selectedPermissions.has(permKey);
return (
<label
key={permKey}
className={`flex items-center px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => togglePermission(permKey)}
className="sr-only"
/>
<span className="text-sm capitalize">{action}</span>
</label>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Members */}
{!isNew && (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-gray-900">Members</h2>
<button className="flex items-center px-3 py-1.5 text-sm bg-indigo-50 text-indigo-700 rounded-lg hover:bg-indigo-100">
<Plus className="w-4 h-4 mr-1" />
Add Member
</button>
</div>
<p className="text-sm text-gray-500">12 users have this role</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Plus, Search, Filter, Download } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface Tenant {
id: string;
name: string;
slug: string;
type: 'municipality' | 'enterprise' | 'partner';
status: 'active' | 'suspended' | 'cancelled' | 'trial';
org_number: string;
user_count: number;
created_at: string;
}
export function TenantList() {
const { hasPermission } = usePermissions();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['tenants', { search, type: typeFilter, status: statusFilter }],
queryFn: () =>
api.get('/tenants', {
params: { search, type: typeFilter, status: statusFilter },
}),
});
const tenants: Tenant[] = data?.data ?? [];
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'trial':
return 'bg-yellow-100 text-yellow-800';
case 'suspended':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'municipality':
return 'Municipality';
case 'enterprise':
return 'Enterprise';
case 'partner':
return 'Partner';
default:
return type;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Tenants</h1>
{hasPermission('tenants.create') && (
<Link
to="/tenants/new"
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
New Tenant
</Link>
)}
</div>
{/* Filters */}
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg shadow">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search tenants..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Types</option>
<option value="municipality">Municipality</option>
<option value="enterprise">Enterprise</option>
<option value="partner">Partner</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="trial">Trial</option>
<option value="suspended">Suspended</option>
</select>
<button className="flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50">
<Download className="w-4 h-4 mr-2" />
Export
</button>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Org Number
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Users
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
Loading...
</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-4 text-center text-gray-500">
No tenants found
</td>
</tr>
) : (
tenants.map((tenant) => (
<tr key={tenant.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div>
<div className="text-sm font-medium text-gray-900">
{tenant.name}
</div>
<div className="text-sm text-gray-500">{tenant.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900">
{getTypeLabel(tenant.type)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{tenant.org_number}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{tenant.user_count}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
tenant.status
)}`}
>
{tenant.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(tenant.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
to={`/tenants/${tenant.id}`}
className="text-indigo-600 hover:text-indigo-900"
>
View
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Plus, Search, Mail, Lock, Unlock } from 'lucide-react';
import { api } from '@/lib/api';
import { usePermissions } from '@/hooks/usePermissions';
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
status: 'active' | 'inactive' | 'pending' | 'locked';
roles: { name: string }[];
last_login_at: string | null;
created_at: string;
}
export function UserList() {
const { hasPermission } = usePermissions();
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['users', { search, role: roleFilter, status: statusFilter }],
queryFn: () =>
api.get('/users', {
params: { search, role: roleFilter, status: statusFilter },
}),
});
const users: User[] = data?.data ?? [];
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'locked':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
{hasPermission('users.create') && (
<Link
to="/users/invite"
className="flex items-center px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
Invite User
</Link>
)}
</div>
{/* Filters */}
<div className="flex items-center space-x-4 bg-white p-4 rounded-lg shadow">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="locked">Locked</option>
</select>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
Loading...
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
No users found
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center mr-3">
<span className="text-sm font-medium text-indigo-600">
{user.first_name?.[0]}{user.last_name?.[0]}
</span>
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{user.first_name} {user.last_name}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-wrap gap-1">
{user.roles.map((role, i) => (
<span
key={i}
className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded"
>
{role.name}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
user.status
)}`}
>
{user.status === 'locked' && <Lock className="w-3 h-3 mr-1" />}
{user.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login_at
? new Date(user.last_login_at).toLocaleString()
: 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
{hasPermission('users.update') && (
<Link
to={`/users/${user.id}`}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</Link>
)}
{hasPermission('users.update') && user.status === 'locked' && (
<button className="text-green-600 hover:text-green-900">
<Unlock className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
first_name: string;
last_name: string;
avatar_url?: string;
roles: { id: string; name: string; permissions: string[] }[];
tenant_id: string;
}
interface Tokens {
accessToken: string;
refreshToken: string;
}
interface AuthState {
user: User | null;
tokens: Tokens | null;
setAuth: (user: User | null, tokens: Tokens | null) => void;
clearAuth: () => void;
updateUser: (user: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
tokens: null,
setAuth: (user, tokens) => set({ user, tokens }),
clearAuth: () => set({ user: null, tokens: null }),
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}),
{
name: 'landvex-auth',
partialize: (state) => ({ tokens: state.tokens }),
}
)
);