05/06 Updated markdown files
This commit is contained in:
@@ -6,8 +6,8 @@ A multi-tenant SaaS POS web application for nail salons. The platform is split a
|
|||||||
|
|
||||||
| Domain | Audience | Purpose |
|
| Domain | Audience | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `admin.mydomain.com` | System Admins | Platform management: system users, tenants, plan enforcement, settings override |
|
| `posadmin.ngodanguyen.tech` | System Admins | Platform management: system users, tenants, plan enforcement, settings override |
|
||||||
| `mydomain.com` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports |
|
| `pos.ngodanguyen.tech` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports |
|
||||||
|
|
||||||
A **System Admin** manages the entire platform and can override any tenant's settings. Each **Tenant** (salon owner/manager/staff) operates exclusively within their own salon context, accessed via `mydomain.com/login` with tenant resolved from their credentials.
|
A **System Admin** manages the entire platform and can override any tenant's settings. Each **Tenant** (salon owner/manager/staff) operates exclusively within their own salon context, accessed via `mydomain.com/login` with tenant resolved from their credentials.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ A **System Admin** manages the entire platform and can override any tenant's set
|
|||||||
|
|
||||||
## Domain Architecture
|
## Domain Architecture
|
||||||
|
|
||||||
### Domain 1 — `admin.mydomain.com` (System Admin Portal)
|
### Domain 1 — `posadmin.ngodanguyen.tech` (System Admin Portal)
|
||||||
|
|
||||||
Served by a dedicated Nginx `server` block. Access is restricted to users with the `superadmin` role. An IP allowlist at the Nginx level provides an additional layer of protection.
|
Served by a dedicated Nginx `server` block. Access is restricted to users with the `superadmin` role. An IP allowlist at the Nginx level provides an additional layer of protection.
|
||||||
|
|
||||||
@@ -49,17 +49,17 @@ Served by a dedicated Nginx `server` block. Access is restricted to users with t
|
|||||||
- **Audit log** — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention
|
- **Audit log** — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention
|
||||||
- **Platform analytics** — active tenants, MRR, trial conversions, churn rate, revenue trends
|
- **Platform analytics** — active tenants, MRR, trial conversions, churn rate, revenue trends
|
||||||
|
|
||||||
### Domain 2 — `mydomain.com` (Tenant Portal)
|
### Domain 2 — `pos.ngodanguyen.tech` (Tenant Portal)
|
||||||
|
|
||||||
Served by a separate Nginx `server` block. All tenants share this single domain. The active tenant and location are resolved from the authenticated session.
|
Served by a separate Nginx `server` block. All tenants share this single domain (`pos.ngodanguyen.tech`). The active tenant and location are resolved from the authenticated session.
|
||||||
|
|
||||||
`mydomain.com` exposes **two distinct login flows**:
|
`mydomain.com` exposes **two distinct login flows**:
|
||||||
|
|
||||||
| URL | Credentials | Who uses it |
|
| URL | Credentials | Who uses it |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mydomain.com/login` | Email + password | `tenant_admin`, `tenant_manager` |
|
| `pos.ngodanguyen.tech/login` | Email + password | `tenant_admin`, `tenant_manager` |
|
||||||
| `mydomain.com/staff-login` | Phone number + passcode (4–6 digit PIN) | `tenant_staff` — kiosk-style login |
|
| `pos.ngodanguyen.tech/staff-login` | Phone number + passcode (4–6 digit PIN) | `tenant_staff` — kiosk-style login |
|
||||||
| `mydomain.com/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad |
|
| `pos.ngodanguyen.tech/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad |
|
||||||
|
|
||||||
Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal **Staff Portal** — a simplified view scoped exclusively to their own data.
|
Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal **Staff Portal** — a simplified view scoped exclusively to their own data.
|
||||||
|
|
||||||
@@ -177,6 +177,11 @@ audit_log
|
|||||||
before_json, after_json, ip_address, created_at
|
before_json, after_json, ip_address, created_at
|
||||||
(append-only; no UPDATE or DELETE ever issued against this table)
|
(append-only; no UPDATE or DELETE ever issued against this table)
|
||||||
(retention: purge records older than 365 days via monthly APScheduler job)
|
(retention: purge records older than 365 days via monthly APScheduler job)
|
||||||
|
|
||||||
|
jwt_blocklist
|
||||||
|
id, jti, created_at
|
||||||
|
(stores revoked JWT refresh token JTIs; persisted to DB — survives Gunicorn worker restarts;
|
||||||
|
checked on every /api/auth/refresh call; purged with expired tokens via APScheduler)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tenant-Level Tables (all carry tenant_id)
|
### Tenant-Level Tables (all carry tenant_id)
|
||||||
@@ -639,17 +644,29 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
## Development Phases
|
## Development Phases
|
||||||
|
|
||||||
### Phase 1 — Foundation
|
### Phase 1 — Foundation ✅ COMPLETE
|
||||||
- [ ] Project scaffold: two app factories, shared extensions, config classes
|
- [x] Project scaffold: two app factories (`create_admin_app`, `create_tenant_app`), shared `extensions.py`, `config.py` (Dev/Prod/Test)
|
||||||
- [ ] Shared models: `SystemUser`, `Tenant`, `Plan`, `TenantSettingOverride`, `AuditLog`
|
- [x] Platform models: `SystemUser`, `Tenant`, `Plan`, `TenantBillingHistory`, `TenantSettingOverride`, `AuditLog`, `JWTBlocklist`
|
||||||
- [ ] Tenant-level models: `Location`, `LocationSetting`, `User`
|
- [x] Tenant-level models: all 26 models including `Location`, `User`, `Staff`, `Appointment`, `Transaction`, `CheckinQueue`, `JWTBlocklist`, etc.
|
||||||
- [ ] `load_tenant_context()` + `load_location_context()` hooks
|
- [x] `load_tenant_context()` + `load_location_context()` before-request hooks
|
||||||
- [ ] Admin portal auth (login/logout, brute-force lockout, `@require_role`)
|
- [x] Admin portal auth — login/logout, brute-force lockout (5 attempts → 15 min), password reset with time-limited token
|
||||||
- [ ] Tenant portal auth (login/logout, password reset, brute-force lockout)
|
- [x] Tenant portal auth — login/logout, password reset, brute-force lockout, demo login shortcut
|
||||||
- [ ] `@demo_readonly` decorator — blocks all write operations on the demo tenant
|
- [x] Staff portal auth — phone + passcode login, per-staff brute-force lockout, logout
|
||||||
- [ ] Demo account: `mydomain.com/demo` shortcut, pre-seeded read-only data
|
- [x] `@require_role` decorator — role enforcement on all routes
|
||||||
- [ ] Database migrations baseline
|
- [x] `@tenant_feature_required` decorator — plan feature flag gating
|
||||||
- [ ] Two Gunicorn entrypoints + two systemd units + Nginx config (with security headers)
|
- [x] `@demo_readonly` decorator — blocks all write operations on demo tenant
|
||||||
|
- [x] `app/security.py` — security headers middleware (CSP with CDN allowance), IP allowlist, input sanitisers
|
||||||
|
- [x] `app/decorators.py`, `app/context.py`, `app/forms.py` — cross-cutting concerns
|
||||||
|
- [x] Base templates — admin portal (Bootstrap 5, sidebar nav) + tenant portal (Bootstrap 5, sidebar nav, location switcher)
|
||||||
|
- [x] Auth templates — login, password reset (request + confirm), account locked, staff PIN login, feature unavailable
|
||||||
|
- [x] Database migrations — circular FK resolved (`appointments` ↔ `transactions` via `use_alter=True`); `db upgrade` verified
|
||||||
|
- [x] `wsgi_admin.py` + `wsgi_tenant.py` Gunicorn entrypoints
|
||||||
|
- [x] `deploy/salon_pos_admin.service` + `deploy/salon_pos_tenant.service` systemd units
|
||||||
|
- [x] `deploy/nginx.conf` — both domains (`posadmin.ngodanguyen.tech`, `pos.ngodanguyen.tech`), TLS, CSP updated for Bootstrap CDN
|
||||||
|
- [x] `deploy/backup/db_backup.sh` + `backup.cron` — daily 02:00, 30-day retention
|
||||||
|
- [x] `tests/` — `conftest.py`, `test_admin_auth.py`, `test_tenant_auth.py`, `test_staff_login.py`, `test_tenancy_isolation.py`, `test_security_headers.py`
|
||||||
|
- [x] `README.md` — full deployment runbook (fresh install, migrations, seeding, service management, backup)
|
||||||
|
- [ ] Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow)
|
||||||
|
|
||||||
### Phase 2 — Admin Portal
|
### Phase 2 — Admin Portal
|
||||||
- [ ] System user management (CRUD, force password reset)
|
- [ ] System user management (CRUD, force password reset)
|
||||||
@@ -812,11 +829,11 @@ DATABASE_URL=mysql+pymysql://salon_pos_app:password@localhost/salon_pos
|
|||||||
MAIL_SERVER=smtp.example.com
|
MAIL_SERVER=smtp.example.com
|
||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_USE_TLS=true
|
MAIL_USE_TLS=true
|
||||||
MAIL_USERNAME=noreply@mydomain.com
|
MAIL_USERNAME=noreply@ngodanguyen.tech
|
||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
||||||
ADMIN_DOMAIN=admin.mydomain.com
|
ADMIN_DOMAIN=posadmin.ngodanguyen.tech
|
||||||
TENANT_DOMAIN=mydomain.com
|
TENANT_DOMAIN=pos.ngodanguyen.tech
|
||||||
DEMO_TENANT_SLUG=demo
|
DEMO_TENANT_SLUG=demo
|
||||||
BACKUP_DIR=/var/backups/salon_pos
|
BACKUP_DIR=/var/backups/salon_pos
|
||||||
BACKUP_RETAIN_DAYS=30
|
BACKUP_RETAIN_DAYS=30
|
||||||
@@ -862,6 +879,44 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Implementation Notes
|
||||||
|
|
||||||
|
The following decisions and resolutions were made during Phase 1 scaffold implementation.
|
||||||
|
|
||||||
|
### Real-Time Alert Mechanism
|
||||||
|
Polling (5-second `setInterval` hitting `GET /api/v1/checkin/queue?status=waiting`) was chosen over SSE and WebSockets for the receptionist dashboard check-in alert. The rationale: the VPS has 4 GB RAM running two Gunicorn processes, MySQL, and Nginx. SSE requires async workers (gevent/eventlet) to avoid blocking Gunicorn worker slots; WebSockets requires Flask-SocketIO plus a message broker (Redis). Polling adds zero infrastructure overhead and the 5-second latency is acceptable for a front-desk use case.
|
||||||
|
|
||||||
|
### Circular Foreign Key Resolution
|
||||||
|
`appointments.rebooked_from_transaction_id` → `transactions` and `transactions.appointment_id` → `appointments` form a circular FK dependency that prevents MySQL InnoDB from creating either table. Both FKs are declared with `use_alter=True, name="fk_..."` so SQLAlchemy emits them as deferred `ALTER TABLE` statements after all tables are created. This is the correct permanent fix — not a workaround.
|
||||||
|
|
||||||
|
### Dual App Factory — Static File Isolation
|
||||||
|
Each Flask app factory (`create_admin_app`, `create_tenant_app`) is configured with:
|
||||||
|
- `static_folder="../static/<portal>"` — Flask serves directly from `static/admin/` or `static/tenant/`
|
||||||
|
- `static_url_path="/static"` — both portals use `/static/css/...` URLs with no portal-name prefix in the path
|
||||||
|
|
||||||
|
This eliminates the double-prefix bug (`/static/admin/admin/css/...`) that occurs when `static_folder` points at the parent `static/` directory.
|
||||||
|
|
||||||
|
### Content Security Policy — CDN Allowance
|
||||||
|
Bootstrap 5 CSS/JS and Bootstrap Icons are loaded from `cdn.jsdelivr.net`. The CSP must explicitly allow this CDN in `script-src`, `style-src`, and `font-src` directives. This is set in both `app/security.py` (Flask layer) and `deploy/nginx.conf` (Nginx layer, which takes precedence in production). Both must be consistent or Nginx will override Flask's permissive header with the restrictive one.
|
||||||
|
|
||||||
|
### Admin Portal Login URL
|
||||||
|
The admin auth blueprint uses `url_prefix=""` (not `/admin`). The login page is at `posadmin.ngodanguyen.tech/login`. Visiting `/` redirects to `/login` for unauthenticated users and to `/dashboard` for authenticated ones.
|
||||||
|
|
||||||
|
### JWT Blocklist Table Location
|
||||||
|
`jwt_blocklist` is defined in `app/models/platform.py` (not `salon.py`) because it is a platform-level concern — shared across all tenants, not scoped to any one salon. Refresh token revocation must survive Gunicorn worker restarts, so in-memory storage is not used.
|
||||||
|
|
||||||
|
### Template Path Resolution
|
||||||
|
Both portal apps set `template_folder` to the project-root `templates/` directory. All `render_template()` calls and `{% extends %}` directives must use the full path from that root:
|
||||||
|
- Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}`
|
||||||
|
- Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}`
|
||||||
|
|
||||||
|
### Phase 2 Blueprint Stubs
|
||||||
|
All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Decisions Log
|
## Decisions Log
|
||||||
|
|
||||||
| # | Decision | Resolution |
|
| # | Decision | Resolution |
|
||||||
@@ -884,4 +939,12 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
|
|||||||
| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. |
|
| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. |
|
||||||
| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. |
|
| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. |
|
||||||
| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and surfaces an alert on the receptionist dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
|
| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and surfaces an alert on the receptionist dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
|
||||||
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
|
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
|
||||||
|
| 20 | Real-time alert mechanism | 5-second polling chosen over SSE/WebSockets for receptionist check-in alerts. VPS has 4 GB RAM — SSE needs async Gunicorn workers; WebSockets needs Flask-SocketIO + Redis. Polling adds zero infrastructure overhead. Endpoint: `GET /api/v1/checkin/queue?status=waiting`. |
|
||||||
|
| 21 | Circular FK resolution | `appointments.rebooked_from_transaction_id` → `transactions` and `transactions.appointment_id` → `appointments` form a cycle. Both FKs use `use_alter=True` so SQLAlchemy defers them as `ALTER TABLE` statements post-creation. |
|
||||||
|
| 22 | Static file serving | Each app factory points `static_folder` to `static/admin/` or `static/tenant/` directly, with `static_url_path="/static"`. Eliminates double-prefix URL bug (`/static/admin/admin/css/...`). |
|
||||||
|
| 23 | Content Security Policy | CSP allows `cdn.jsdelivr.net` in `script-src`, `style-src`, and `font-src` for Bootstrap 5 and Bootstrap Icons. Must be set consistently in both `app/security.py` and `deploy/nginx.conf` — Nginx overrides Flask headers in production. |
|
||||||
|
| 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). |
|
||||||
|
| 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. |
|
||||||
|
| 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. |
|
||||||
|
| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. |
|
||||||
@@ -6,8 +6,8 @@ A multi-tenant SaaS POS web application for nail salons, served across two dedic
|
|||||||
|
|
||||||
| Domain | Audience | Purpose |
|
| Domain | Audience | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `admin.mydomain.com` | System Admins | Platform management |
|
| `posadmin.ngodanguyen.tech` | System Admins | Platform management |
|
||||||
| `mydomain.com` | Tenant users | Salon management |
|
| `pos.ngodanguyen.tech` | Tenant users | Salon management |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,6 +93,8 @@ flask --app wsgi_tenant:app db upgrade
|
|||||||
|
|
||||||
### 7. Seed initial data (plans + superadmin)
|
### 7. Seed initial data (plans + superadmin)
|
||||||
|
|
||||||
|
> **Important:** There is no default seeded admin user. You must create one manually using the shell commands below. Choose a strong password — minimum 10 characters, at least one uppercase letter, one lowercase letter, and one digit.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flask --app wsgi_admin:app shell
|
flask --app wsgi_admin:app shell
|
||||||
```
|
```
|
||||||
@@ -160,9 +162,6 @@ sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant
|
|||||||
sudo cp deploy/salon_pos_admin.service /etc/systemd/system/
|
sudo cp deploy/salon_pos_admin.service /etc/systemd/system/
|
||||||
sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/
|
sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo mkdir -p /run/salon_pos
|
|
||||||
sudo chown salonpos:salonpos /run/salon_pos
|
|
||||||
sudo chmod 775 /run/salon_pos
|
|
||||||
sudo systemctl enable salon_pos_admin salon_pos_tenant
|
sudo systemctl enable salon_pos_admin salon_pos_tenant
|
||||||
sudo systemctl start salon_pos_admin salon_pos_tenant
|
sudo systemctl start salon_pos_admin salon_pos_tenant
|
||||||
sudo systemctl status salon_pos_admin salon_pos_tenant
|
sudo systemctl status salon_pos_admin salon_pos_tenant
|
||||||
@@ -263,14 +262,38 @@ pip install -r requirements.txt
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Set FLASK_ENV=development in .env
|
# Set FLASK_ENV=development in .env
|
||||||
flask --app wsgi_tenant:app db upgrade
|
flask --app wsgi_tenant:app db upgrade
|
||||||
flask --app wsgi_tenant:app run --port 5001
|
flask --app wsgi_tenant:app run --port 5001 # Tenant portal → http://localhost:5001/login
|
||||||
flask --app wsgi_admin:app run --port 5002
|
flask --app wsgi_admin:app run --port 5002 # Admin portal → http://localhost:5002/login
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
## Phase 1 Resolutions
|
||||||
|
|
||||||
|
The following issues were encountered and resolved during Phase 1 — documented here to inform future contributors.
|
||||||
|
|
||||||
|
### Circular Foreign Keys (MySQL InnoDB)
|
||||||
|
`appointments.rebooked_from_transaction_id` and `transactions.appointment_id` form a circular FK dependency. MySQL InnoDB enforces FK constraints at `CREATE TABLE` time so neither table could be created first. **Fix:** both columns use `use_alter=True, name="fk_..."` so SQLAlchemy defers them as `ALTER TABLE` statements after all tables are created.
|
||||||
|
|
||||||
|
If you re-generate migrations from scratch, always verify with:
|
||||||
|
```bash
|
||||||
|
flask --app wsgi_tenant:app db upgrade
|
||||||
|
```
|
||||||
|
Any `(1824, "Failed to open the referenced table ...")` error means a circular FK is missing `use_alter=True`.
|
||||||
|
|
||||||
|
### Static File Double-Prefix
|
||||||
|
Flask apps with `static_folder="../static"` and `static_url_path="/static/admin"` produce URLs like `/static/admin/css/admin.css`. If templates then call `url_for('static', filename='admin/css/admin.css')` the URL becomes `/static/admin/admin/css/admin.css` (doubled prefix). **Fix:** each app points `static_folder` directly at its subdirectory (`static/admin/` or `static/tenant/`) with `static_url_path="/static"`. Templates use `url_for('static', filename='css/admin.css')` with no portal prefix in the filename.
|
||||||
|
|
||||||
|
### Content Security Policy and Bootstrap CDN
|
||||||
|
The CSP must explicitly allow `https://cdn.jsdelivr.net` in `script-src`, `style-src`, and `font-src`. Both `app/security.py` (Flask after-request hook) and `deploy/nginx.conf` must be consistent — Nginx headers override Flask headers in production.
|
||||||
|
|
||||||
|
### Admin Login URL
|
||||||
|
The admin portal login page is at `posadmin.ngodanguyen.tech/login` (not `/admin/login`). The admin auth blueprint uses `url_prefix=""`. Visiting `/` redirects to `/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
```
|
```
|
||||||
salon_pos/
|
salon_pos/
|
||||||
├── app/
|
├── app/
|
||||||
@@ -283,8 +306,8 @@ salon_pos/
|
|||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ...
|
│ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ...
|
||||||
│ │ └── salon.py # All tenant-scoped models
|
│ │ └── salon.py # All tenant-scoped models
|
||||||
│ ├── admin/ # admin.mydomain.com blueprints
|
│ ├── admin/ # posadmin.ngodanguyen.tech blueprints
|
||||||
│ └── tenant/ # mydomain.com blueprints
|
│ └── tenant/ # pos.ngodanguyen.tech blueprints
|
||||||
├── config.py # Dev / Prod / Test config classes
|
├── config.py # Dev / Prod / Test config classes
|
||||||
├── wsgi_admin.py # Gunicorn entrypoint — admin
|
├── wsgi_admin.py # Gunicorn entrypoint — admin
|
||||||
├── wsgi_tenant.py # Gunicorn entrypoint — tenant
|
├── wsgi_tenant.py # Gunicorn entrypoint — tenant
|
||||||
@@ -292,4 +315,4 @@ salon_pos/
|
|||||||
├── .env.example
|
├── .env.example
|
||||||
├── deploy/ # systemd units, Nginx config, backup scripts
|
├── deploy/ # systemd units, Nginx config, backup scripts
|
||||||
└── tests/ # pytest test suites
|
└── tests/ # pytest test suites
|
||||||
```
|
```
|
||||||
Reference in New Issue
Block a user