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:
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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;"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user