05/06/2026 Initial commit

This commit is contained in:
2026-05-06 14:19:07 -04:00
parent 9da1dffd9a
commit dde18a2cd2
116 changed files with 4276 additions and 7 deletions
+23
View File
@@ -0,0 +1,23 @@
FLASK_ENV=production
SECRET_KEY=change-me-to-a-random-256-bit-key
ADMIN_SECRET_KEY=separate-key-for-admin-portal-sessions
DATABASE_URL=mysql+pymysql://salon_pos_app:password@localhost/salon_pos
MAIL_SERVER=smtp.example.com
MAIL_PORT=587
MAIL_USE_TLS=true
MAIL_USERNAME=noreply@mydomain.com
MAIL_PASSWORD=
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
ADMIN_DOMAIN=admin.mydomain.com
TENANT_DOMAIN=mydomain.com
DEMO_TENANT_SLUG=demo
BACKUP_DIR=/var/backups/salon_pos
BACKUP_RETAIN_DAYS=30
SESSION_TIMEOUT_TENANT=1800
SESSION_TIMEOUT_ADMIN=3600
JWT_ACCESS_TOKEN_EXPIRES=900
JWT_REFRESH_TOKEN_EXPIRES=604800
MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15
STAFF_PASSCODE_MIN_LENGTH=4
STAFF_PASSCODE_MAX_LENGTH=6
+4 -4
View File
@@ -82,7 +82,7 @@ The **Customer Check-In Kiosk** (`mydomain.com/checkin/{tenant_slug}`) is a sepa
- No access to other staffs data, full customer PII, reports, inventory, or any settings - No access to other staffs data, full customer PII, reports, inventory, or any settings
- **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge - **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge
- **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously) - **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously)
- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and a real-time alert is pushed to the receptionists dashboard; the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration - **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and an alert is surfaced on the receptionists dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`); the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration
- **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes - **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes
- **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans - **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans
- **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase - **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase
@@ -671,7 +671,7 @@ WantedBy=multi-user.target
- [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products) - [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products)
- [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically - [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically
- [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record) - [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record)
- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; real-time dashboard alert to receptionist; auto-reset timer; rate limiting; `service_requested` selector) - [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; receptionist alert via 5-second polling (`GET /api/v1/checkin/queue?status=waiting`); auto-reset timer; rate limiting; `service_requested` selector)
- [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle) - [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle)
- [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar) - [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar)
- [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile) - [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile)
@@ -764,7 +764,7 @@ WantedBy=multi-user.target
| Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation | | Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation |
| Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` | | Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` |
| Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data | | Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data |
| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup | | Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup; receptionist dashboard alert delivered via 5-second JS polling (`GET /api/v1/checkin/queue?status=waiting`) — no WebSockets or SSE required |
| Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant | | Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant |
| Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement | | Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement |
@@ -883,5 +883,5 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
| 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. | | 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. |
| 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 pushes a real-time alert to the receptionist dashboard. 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. |
+291 -2
View File
@@ -1,3 +1,292 @@
# MyPOS # Salon POS — Multi-Tenant SaaS
POS system for Nails Salon ## Overview
A multi-tenant SaaS POS web application for nail salons, served across two dedicated domains:
| Domain | Audience | Purpose |
|---|---|---|
| `admin.mydomain.com` | System Admins | Platform management |
| `mydomain.com` | Tenant users | Salon management |
---
## Tech Stack
- **Backend:** Python 3.11+ / Flask
- **Database:** MySQL 8.0+ with SQLAlchemy ORM
- **Auth:** Flask-Login (UI) + Flask-JWT-Extended (API)
- **Frontend:** Jinja2 + Bootstrap 5 + Vanilla JS
- **WSGI:** Gunicorn (2 independent processes)
- **Reverse Proxy:** Nginx
- **Scheduler:** APScheduler
- **Process Manager:** systemd
---
## Fresh Install (Production)
### 1. System dependencies
```bash
sudo apt update && sudo apt install -y python3.11 python3.11-venv python3-pip \
mysql-server nginx libmysqlclient-dev build-essential libssl-dev
```
### 2. Application user
```bash
sudo useradd -m -s /bin/bash salonpos
sudo mkdir -p /opt/salon_pos
sudo chown salonpos:salonpos /opt/salon_pos
```
### 3. Clone and set up virtual environment
```bash
cd /opt/salon_pos
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### 4. Environment configuration
```bash
cp .env.example .env
chmod 600 .env
# Edit .env with your values — SECRET_KEY, ADMIN_SECRET_KEY, DATABASE_URL, MAIL_*, etc.
nano .env
```
### 5. MySQL setup
```sql
CREATE DATABASE salon_pos CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'salon_pos_app'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP
ON salon_pos.* TO 'salon_pos_app'@'localhost';
-- Backup user (read-only)
CREATE USER 'salon_pos_backup'@'localhost' IDENTIFIED BY 'backup-password-here';
GRANT SELECT, LOCK TABLES ON salon_pos.* TO 'salon_pos_backup'@'localhost';
FLUSH PRIVILEGES;
```
Store backup credentials in `/etc/mysql/backup.cnf` (mode 600):
```ini
[client]
user=salon_pos_backup
password=backup-password-here
host=localhost
```
### 6. Database migrations
```bash
cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db init # First time only
flask --app wsgi_tenant:app db migrate -m "Phase 1 — initial schema"
flask --app wsgi_tenant:app db upgrade
```
### 7. Seed initial data (plans + superadmin)
```bash
flask --app wsgi_admin:app shell
```
```python
from app.extensions import db, bcrypt
from app.models.platform import Plan, SystemUser
# Create subscription plans
plans = [
Plan(name="Starter", price_monthly=29.00, max_staff=3, max_locations=1,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": False, "commission": False,
"full_reports": False, "multi_location": False,
"online_booking": False, "waitlist": False,
"marketing": False}),
Plan(name="Growth", price_monthly=59.00, max_staff=10, max_locations=3,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": False}),
Plan(name="Pro", price_monthly=99.00, max_staff=None, max_locations=None,
features_json={"pos": True, "appointments": True, "customers": True,
"services": True, "promotions": True,
"appointment_reminders": True, "customer_reviews": True,
"reconciliation": True, "basic_reports": True,
"inventory": True, "commission": True,
"full_reports": True, "multi_location": True,
"online_booking": True, "waitlist": True,
"marketing": True}),
]
db.session.add_all(plans)
# Create first superadmin
admin = SystemUser(
email="admin@yourdomain.com",
password_hash=bcrypt.generate_password_hash("ChangeMe1!").decode("utf-8"),
name="System Admin",
role="superadmin",
is_active=True,
)
db.session.add(admin)
db.session.commit()
print("Seeded successfully.")
exit()
```
### 8. Log directories
```bash
sudo mkdir -p /var/log/salon_pos_admin /var/log/salon_pos_tenant
sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant
```
### 9. systemd services
```bash
sudo cp deploy/salon_pos_admin.service /etc/systemd/system/
sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable salon_pos_admin salon_pos_tenant
sudo systemctl start salon_pos_admin salon_pos_tenant
sudo systemctl status salon_pos_admin salon_pos_tenant
```
### 10. Nginx
```bash
sudo cp deploy/nginx.conf /etc/nginx/sites-available/salon_pos
sudo ln -s /etc/nginx/sites-available/salon_pos /etc/nginx/sites-enabled/
# Update IP allowlist in nginx.conf before enabling
sudo nginx -t
sudo systemctl reload nginx
```
### 11. Backup cron
```bash
sudo cp deploy/backup/db_backup.sh /opt/salon_pos/deploy/backup/
sudo chmod +x /opt/salon_pos/deploy/backup/db_backup.sh
sudo mkdir -p /var/backups/salon_pos
sudo chown salonpos:salonpos /var/backups/salon_pos
# Install cron job for salonpos user
sudo crontab -u salonpos deploy/backup/backup.cron
```
---
## Service Management
```bash
# Status
sudo systemctl status salon_pos_admin
sudo systemctl status salon_pos_tenant
# Restart
sudo systemctl restart salon_pos_admin
sudo systemctl restart salon_pos_tenant
# View logs
sudo journalctl -u salon_pos_admin -f
sudo journalctl -u salon_pos_tenant -f
tail -f /var/log/salon_pos_admin/error.log
tail -f /var/log/salon_pos_tenant/error.log
```
---
## Database Migrations (Ongoing)
```bash
cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db migrate -m "Description of change"
flask --app wsgi_tenant:app db upgrade
# Rollback one step
flask --app wsgi_tenant:app db downgrade
```
---
## Running Tests
```bash
cd /opt/salon_pos
source venv/bin/activate
pip install pytest
FLASK_ENV=testing pytest tests/ -v
```
---
## Monthly Restore Drill (Mandatory)
```bash
# On staging server:
LATEST=$(ls -t /var/backups/salon_pos/*.sql.gz | head -1)
gunzip -c "$LATEST" | mysql -u root salon_pos_staging
echo "Restore drill completed: $LATEST"
# Document the result and date in this runbook.
```
**Last restore drill:** _______________
**Performed by:** _______________
**Result:** _______________
---
## Development Setup
```bash
git clone <repo>
cd salon_pos
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Set FLASK_ENV=development in .env
flask --app wsgi_tenant:app db upgrade
flask --app wsgi_tenant:app run --port 5001
flask --app wsgi_admin:app run --port 5002
```
---
## Project Structure
```
salon_pos/
├── app/
│ ├── __init__.py
│ ├── extensions.py # Shared Flask extensions
│ ├── context.py # load_tenant_context, load_location_context
│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly
│ ├── security.py # Security headers + input sanitisers
│ ├── forms.py # Password validation helper
│ ├── models/
│ │ ├── platform.py # SystemUser, Tenant, Plan, AuditLog, ...
│ │ └── salon.py # All tenant-scoped models
│ ├── admin/ # admin.mydomain.com blueprints
│ └── tenant/ # mydomain.com blueprints
├── config.py # Dev / Prod / Test config classes
├── wsgi_admin.py # Gunicorn entrypoint — admin
├── wsgi_tenant.py # Gunicorn entrypoint — tenant
├── requirements.txt
├── .env.example
├── deploy/ # systemd units, Nginx config, backup scripts
└── tests/ # pytest test suites
```
+2
View File
@@ -0,0 +1,2 @@
# Shared package marker — do not instantiate apps here.
# Use create_admin_app() or create_tenant_app() instead.
+83
View File
@@ -0,0 +1,83 @@
"""
app/admin/__init__.py — Admin app factory (admin.mydomain.com).
"""
import logging
from datetime import timedelta
from flask import Flask
from app.extensions import db, migrate, csrf, mail, scheduler, admin_login_manager, admin_jwt, limiter
from app.security import apply_security_headers, check_admin_ip
from config import get_config
logger = logging.getLogger(__name__)
def create_admin_app(config_override=None):
flask_app = Flask(
__name__,
template_folder="../templates",
static_folder="../static",
static_url_path="/static/admin",
)
# ── Config ────────────────────────────────────────────────
cfg = config_override or get_config()
flask_app.config.from_object(cfg)
# Admin portal uses a separate secret key and session cookie name
import os
flask_app.config["SECRET_KEY"] = os.environ.get(
"ADMIN_SECRET_KEY", flask_app.config["SECRET_KEY"]
)
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_admin_session"
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
seconds=int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
)
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# ── Extensions ────────────────────────────────────────────
db.init_app(flask_app)
migrate.init_app(flask_app, db)
csrf.init_app(flask_app)
mail.init_app(flask_app)
limiter.init_app(flask_app)
# Admin login manager
admin_login_manager.login_view = "admin_auth.login"
admin_login_manager.login_message_category = "warning"
admin_login_manager.session_protection = "strong"
admin_login_manager.init_app(flask_app)
# Admin JWT
admin_jwt.init_app(flask_app)
# ── User loader ───────────────────────────────────────────
from app.models.platform import SystemUser
@admin_login_manager.user_loader
def load_admin_user(user_id: str):
if not user_id.startswith("system:"):
return None
try:
uid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return SystemUser.query.get(uid)
# ── Security ──────────────────────────────────────────────
apply_security_headers(flask_app)
check_admin_ip(flask_app)
# ── Blueprints ────────────────────────────────────────────
from app.admin.auth.routes import admin_auth_bp
flask_app.register_blueprint(admin_auth_bp)
# Placeholder blueprints registered in later phases:
# system_users, tenants, plans, billing, settings_override, audit_log, analytics
# ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401
logger.info("Admin app created (env=%s)", flask_app.config.get("FLASK_ENV"))
return flask_app
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/analytics/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/audit_log/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log")
View File
+107
View File
@@ -0,0 +1,107 @@
"""
app/admin/auth/routes.py — Admin portal authentication.
Routes: /admin/login, /admin/logout
Brute-force lockout: 5 failures → locked for LOGIN_LOCKOUT_MINUTES.
"""
import logging
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request, current_app, session,
)
from flask_login import login_user, logout_user, login_required, current_user
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import bcrypt
from app.extensions import db, limiter
from app.models.platform import SystemUser, AuditLog
logger = logging.getLogger(__name__)
admin_auth_bp = Blueprint("admin_auth", __name__, url_prefix="/admin")
@admin_auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def login():
if current_user.is_authenticated:
return redirect(url_for("admin_auth.dashboard_redirect"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = SystemUser.query.filter_by(email=email, is_active=True).first()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
if user and user.is_locked():
logger.warning("Admin login blocked — account locked: %s", email)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
# Success — reset lockout counters
user.failed_login_attempts = 0
user.locked_until = None
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user, remember=False)
session.permanent = True
AuditLog.log(
actor_id=user.id,
actor_type="system_user",
action="auth.login",
ip_address=request.remote_addr,
)
db.session.commit()
logger.info("Admin login success: %s", email)
next_url = request.args.get("next") or url_for("admin_auth.dashboard_redirect")
return redirect(next_url)
else:
# Failed attempt
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= max_attempts:
user.locked_until = datetime.now(timezone.utc) + timedelta(
minutes=lockout_minutes
)
logger.warning(
"Admin account locked after %d failures: %s",
max_attempts, email,
)
db.session.commit()
logger.warning("Admin login failed: %s", email)
error = "Invalid email or password."
return render_template("admin/auth/login.html", error=error)
@admin_auth_bp.route("/logout")
@login_required
def logout():
AuditLog.log(
actor_id=current_user.id,
actor_type="system_user",
action="auth.logout",
ip_address=request.remote_addr,
)
db.session.commit()
logout_user()
session.clear()
logger.info("Admin logout")
return redirect(url_for("admin_auth.login"))
@admin_auth_bp.route("/")
@login_required
def dashboard_redirect():
# Will route to the analytics dashboard in Phase 2
return redirect(url_for("admin_auth.login"))
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/billing/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/plans/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/settings_override/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/system_users/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/admin/tenants/routes.py
Phase 2 implementation.
"""
from flask import Blueprint
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
View File
View File
View File
+126
View File
@@ -0,0 +1,126 @@
"""
context.py Tenant and location context resolution.
load_tenant_context() and load_location_context() are registered
as before_request hooks in the tenant app factory.
"""
import logging
from flask import g, session, redirect, url_for, request, abort
from flask_login import current_user
from app.models.platform import Tenant
from app.models.salon import Location, StaffLocation, Staff
logger = logging.getLogger(__name__)
def load_tenant_context():
"""
Resolve g.tenant from the authenticated user's session.
Called on every request in the tenant portal.
Redirects to a locked page if the tenant is suspended or cancelled.
Skips public routes (checkin, booking, staff-login, auth).
"""
# Skip context resolution for public / auth endpoints
public_blueprints = {"tenant_auth", "staff_auth", "checkin", "booking", "static"}
if request.blueprint in public_blueprints:
g.tenant = None
return
if not current_user.is_authenticated:
g.tenant = None
return
# Resolve tenant_id from the authenticated principal
if hasattr(current_user, "tenant_id"):
tenant_id = current_user.tenant_id
else:
g.tenant = None
return
tenant = Tenant.query.get(tenant_id)
if tenant is None:
logger.warning("Tenant %s not found for user %s", tenant_id, current_user)
abort(403)
g.tenant = tenant
# Enforce subscription status
if tenant.status == "suspended":
if request.endpoint != "tenant_auth.suspended":
return redirect(url_for("tenant_auth.suspended"))
if tenant.status == "cancelled":
if request.endpoint != "tenant_auth.cancelled":
return redirect(url_for("tenant_auth.cancelled"))
logger.debug("Tenant context loaded: %s", tenant.slug)
def load_location_context():
"""
Resolve g.location from session or default to the tenant's primary location.
For tenant_staff, validates that the staff member is assigned to the location.
Must be called after load_tenant_context().
"""
if not hasattr(g, "tenant") or g.tenant is None:
g.location = None
return
if not current_user.is_authenticated:
g.location = None
return
tenant_id = g.tenant.id
# Try to load from session
location_id = session.get("active_location_id")
if location_id:
location = Location.query.filter_by(
id=location_id,
tenant_id=tenant_id,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
else:
location = None
# Fall back to primary location
if location is None:
location = Location.query.filter_by(
tenant_id=tenant_id,
is_primary=True,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
if location is None:
# Last resort: first active location
location = Location.query.filter_by(
tenant_id=tenant_id,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
if location:
session["active_location_id"] = location.id
# For tenant_staff: enforce location assignment
if location and hasattr(current_user, "get_id"):
user_id_str = current_user.get_id()
if user_id_str and user_id_str.startswith("staff:"):
staff_id = int(user_id_str.split(":")[1])
assigned = StaffLocation.query.filter_by(
staff_id=staff_id,
location_id=location.id,
tenant_id=tenant_id,
).first()
if not assigned:
logger.warning(
"Staff %s attempted access to unassigned location %s",
staff_id, location.id,
)
abort(403)
g.location = location
logger.debug(
"Location context loaded: %s",
location.name if location else "None",
)
+126
View File
@@ -0,0 +1,126 @@
"""
decorators.py Shared route decorators.
@require_role(*roles) Enforce system role on a route.
@tenant_feature_required(flag) Gate a route behind a plan feature flag.
@demo_readonly Block write operations on the demo tenant.
"""
import logging
import functools
from flask import g, abort, jsonify, request
from flask_login import current_user
logger = logging.getLogger(__name__)
def require_role(*roles):
"""
Decorator that enforces the current user holds one of the specified roles.
Works for both system_users (superadmin) and tenant users / staff.
Usage:
@require_role("tenant_admin")
@require_role("tenant_admin", "tenant_manager")
@require_role("superadmin")
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
user_role = getattr(current_user, "role", None)
if user_role not in roles:
logger.warning(
"Role check failed: user %s has role '%s', required one of %s",
getattr(current_user, "id", "?"), user_role, roles,
)
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def tenant_feature_required(flag: str):
"""
Decorator that gates a route behind a plan feature flag.
Checks g.tenant.plan.features_json for the given flag.
Also respects tenant_setting_overrides for force-enable/disable.
Usage:
@tenant_feature_required("marketing")
@tenant_feature_required("inventory")
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
tenant = getattr(g, "tenant", None)
if tenant is None:
abort(403)
# Check for superadmin override (force-enable / force-disable)
from app.models.platform import TenantSettingOverride
override = TenantSettingOverride.query.filter_by(
tenant_id=tenant.id,
setting_key=f"feature.{flag}",
lifted_at=None,
).first()
if override is not None:
enabled = override.setting_value in ("1", "true", "True")
if not enabled:
logger.info(
"Feature '%s' force-disabled by admin override for tenant %s",
flag, tenant.slug,
)
abort(403)
# force-enabled: proceed regardless of plan
return fn(*args, **kwargs)
# Check plan feature flags
if tenant.plan is None or not tenant.plan.has_feature(flag):
logger.info(
"Feature '%s' not available on plan '%s' for tenant %s",
flag,
tenant.plan.name if tenant.plan else "unknown",
tenant.slug,
)
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def demo_readonly(fn):
"""
Decorator that blocks all write operations (POST, PUT, PATCH, DELETE)
when the active tenant is the demo tenant.
Returns 403 with a JSON or HTML response depending on the request type.
Usage:
@demo_readonly
def create_customer():
...
"""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
tenant = getattr(g, "tenant", None)
if tenant and tenant.is_demo:
logger.info(
"Demo write blocked: %s %s", request.method, request.path
)
if request.is_json or request.path.startswith("/api/"):
return jsonify(
error="Demo account is read-only. "
"Sign up for a full account to make changes."
), 403
from flask import flash, redirect, request as req
flash(
"This is a demo account. Sign up for a full account to make changes.",
"warning",
)
return redirect(req.referrer or "/")
return fn(*args, **kwargs)
return wrapper
+36
View File
@@ -0,0 +1,36 @@
"""
extensions.py Shared Flask extension instances.
Imported by both app factories to avoid circular imports.
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_jwt_extended import JWTManager
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_mail import Mail
from flask_apscheduler import APScheduler
from flask_bcrypt import Bcrypt
db = SQLAlchemy()
migrate = Migrate()
csrf = CSRFProtect()
mail = Mail()
scheduler = APScheduler()
bcrypt = Bcrypt()
# Two separate LoginManager instances — one per app factory.
admin_login_manager = LoginManager()
tenant_login_manager = LoginManager()
# Two separate JWTManager instances — one per app factory.
admin_jwt = JWTManager()
tenant_jwt = JWTManager()
# Rate limiter — shared, keyed by remote address.
limiter = Limiter(key_func=get_remote_address)
jwt = admin_jwt # shared alias used by both app factories
+39
View File
@@ -0,0 +1,39 @@
"""
forms.py
Shared form utilities and password strength validator.
Per-module WTForms classes live in their respective blueprint directories.
"""
import re
_PASSWORD_MIN_LENGTH = 10
_HAS_UPPERCASE = re.compile(r"[A-Z]")
_HAS_LOWERCASE = re.compile(r"[a-z]")
_HAS_DIGIT = re.compile(r"\d")
def validate_password_strength(password: str, confirm: str = None) -> str | None:
"""
Validate password strength per the security policy:
- Minimum 10 characters
- Must include at least one uppercase letter
- Must include at least one lowercase letter
- Must include at least one digit
Returns an error string, or None if the password is acceptable.
If confirm is supplied, also checks they match.
"""
if not password:
return "Password is required."
if len(password) < _PASSWORD_MIN_LENGTH:
return f"Password must be at least {_PASSWORD_MIN_LENGTH} characters."
if not _HAS_UPPERCASE.search(password):
return "Password must include at least one uppercase letter."
if not _HAS_LOWERCASE.search(password):
return "Password must include at least one lowercase letter."
if not _HAS_DIGIT.search(password):
return "Password must include at least one digit."
if confirm is not None and password != confirm:
return "Passwords do not match."
return None
+44
View File
@@ -0,0 +1,44 @@
"""
models/__init__.py
Import all models so Flask-Migrate can discover them.
JWTBlocklist lives in platform.py (platform-level table).
"""
from app.models.platform import ( # noqa: F401
SystemUser,
Plan,
Tenant,
TenantBillingHistory,
TenantSettingOverride,
AuditLog,
JWTBlocklist,
)
from app.models.salon import ( # noqa: F401
TenantSetting,
Location,
LocationSetting,
User,
Customer,
Service,
Product,
Promotion,
Staff,
StaffLocation,
StaffSchedule,
Appointment,
Transaction,
TransactionItem,
Inventory,
InventoryLog,
CommissionLog,
StaffPayPeriod,
StaffClocking,
MarketingCampaign,
GiftCard,
CheckinQueue,
Waitlist,
CheckoutReview,
DailyReconciliation,
AppointmentReminder,
)
+275
View File
@@ -0,0 +1,275 @@
"""
models/platform.py Platform-level models (superadmin scope).
Tables: system_users, plans, tenants, tenant_billing_history,
tenant_setting_overrides, audit_log, jwt_blocklist
"""
import logging
from datetime import datetime, timezone
from app.extensions import db
logger = logging.getLogger(__name__)
class SystemUser(db.Model):
__tablename__ = "system_users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
name = db.Column(db.String(100), nullable=False)
role = db.Column(db.String(50), nullable=False, default="superadmin")
is_active = db.Column(db.Boolean, nullable=False, default=True)
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
password_reset_token = db.Column(db.String(255), nullable=True)
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# Flask-Login interface
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"system:{self.id}"
def is_locked(self):
if self.locked_until is None:
return False
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
def record_login(self):
from datetime import datetime as _dt, timezone as _tz
self.failed_login_attempts = 0
self.locked_until = None
self.last_login_at = _dt.now(_tz.utc)
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
if self.failed_login_attempts >= max_attempts:
self.locked_until = _dt.now(_tz.utc) + _td(minutes=lockout_minutes)
def __repr__(self):
return f"<SystemUser {self.email}>"
class Plan(db.Model):
__tablename__ = "plans"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
price_monthly = db.Column(db.Numeric(8, 2), nullable=False)
max_staff = db.Column(db.Integer, nullable=True) # None = unlimited
max_locations = db.Column(db.Integer, nullable=True) # None = unlimited
features_json = db.Column(db.JSON, nullable=False, default=dict)
is_active = db.Column(db.Boolean, nullable=False, default=True)
tenants = db.relationship("Tenant", back_populates="plan", lazy="dynamic")
def has_feature(self, flag: str) -> bool:
return bool(self.features_json.get(flag, False))
def __repr__(self):
return f"<Plan {self.name}>"
class Tenant(db.Model):
__tablename__ = "tenants"
id = db.Column(db.Integer, primary_key=True)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
owner_email = db.Column(db.String(255), nullable=False)
plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False)
status = db.Column(db.String(20), nullable=False, default="trial")
# status: 'active' | 'trial' | 'suspended' | 'cancelled'
trial_ends_at = db.Column(db.DateTime, nullable=True)
subscription_expires_at = db.Column(db.DateTime, nullable=True)
is_demo = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
plan = db.relationship("Plan", back_populates="tenants")
billing_history = db.relationship(
"TenantBillingHistory", back_populates="tenant", lazy="dynamic"
)
setting_overrides = db.relationship(
"TenantSettingOverride", back_populates="tenant", lazy="dynamic"
)
def is_suspended(self):
return self.status in ("suspended", "cancelled")
def is_active_status(self):
return self.status in ("active", "trial")
def get_setting(self, key: str, default=None):
"""
Resolve a tenant setting, honouring superadmin overrides.
Override takes precedence if lifted_at is NULL.
Falls back to tenant_settings, then the supplied default.
"""
override = (
TenantSettingOverride.query
.filter_by(tenant_id=self.id, setting_key=key)
.filter(TenantSettingOverride.lifted_at.is_(None))
.first()
)
if override:
return override.setting_value
from app.models.salon import TenantSetting
setting = TenantSetting.query.filter_by(
tenant_id=self.id, setting_key=key
).first()
return setting.setting_value if setting else default
def has_feature(self, flag: str) -> bool:
"""Check plan feature flag, allowing active overrides to force-enable/disable."""
override = (
TenantSettingOverride.query
.filter_by(tenant_id=self.id, setting_key=f"feature_{flag}")
.filter(TenantSettingOverride.lifted_at.is_(None))
.first()
)
if override:
return override.setting_value.lower() in ("true", "1", "yes")
return self.plan.has_feature(flag) if self.plan else False
def __repr__(self):
return f"<Tenant {self.slug}>"
class TenantBillingHistory(db.Model):
__tablename__ = "tenant_billing_history"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
amount = db.Column(db.Numeric(10, 2), nullable=False)
description = db.Column(db.String(255), nullable=False)
paid_at = db.Column(db.DateTime, nullable=True)
invoice_ref = db.Column(db.String(100), nullable=True)
recorded_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
nullable=True)
tenant = db.relationship("Tenant", back_populates="billing_history")
recorder = db.relationship("SystemUser")
def __repr__(self):
return f"<BillingHistory tenant={self.tenant_id} amount={self.amount}>"
class TenantSettingOverride(db.Model):
__tablename__ = "tenant_setting_overrides"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
overridden_by = db.Column(db.Integer, db.ForeignKey("system_users.id"),
nullable=False)
overridden_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
lifted_at = db.Column(db.DateTime, nullable=True)
note = db.Column(db.Text, nullable=True)
tenant = db.relationship("Tenant", back_populates="setting_overrides")
admin = db.relationship("SystemUser")
@property
def is_active(self):
return self.lifted_at is None
def __repr__(self):
return f"<SettingOverride tenant={self.tenant_id} key={self.setting_key}>"
class AuditLog(db.Model):
"""
Immutable, append-only audit log.
Never issue UPDATE or DELETE against this table from application code.
Retention: records older than 365 days purged monthly via APScheduler.
"""
__tablename__ = "audit_log"
id = db.Column(db.Integer, primary_key=True)
actor_id = db.Column(db.Integer, nullable=False)
actor_type = db.Column(db.String(50), nullable=False)
# actor_type: 'system_user' | 'tenant_user'
action = db.Column(db.String(100), nullable=False)
target_type = db.Column(db.String(100), nullable=True)
target_id = db.Column(db.Integer, nullable=True)
before_json = db.Column(db.JSON, nullable=True)
after_json = db.Column(db.JSON, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True)
@classmethod
def log(cls, actor_id, actor_type, action,
target_type=None, target_id=None,
before=None, after=None, ip_address=None):
"""
Helper to append an audit entry and flush to DB.
Usage:
AuditLog.log(
actor_id=current_user.id,
actor_type='system_user',
action='tenant.suspend',
target_type='tenant',
target_id=tenant.id,
before={'status': 'active'},
after={'status': 'suspended'},
ip_address=request.remote_addr,
)
"""
entry = cls(
actor_id=actor_id,
actor_type=actor_type,
action=action,
target_type=target_type,
target_id=target_id,
before_json=before,
after_json=after,
ip_address=ip_address,
)
db.session.add(entry)
logger.info(
"AUDIT | actor=%s(%s) action=%s target=%s/%s",
actor_type, actor_id, action, target_type, target_id,
)
return entry
def __repr__(self):
return f"<AuditLog {self.action} by {self.actor_type}:{self.actor_id}>"
class JWTBlocklist(db.Model):
"""
Stores revoked JWT refresh token JTIs.
Checked on every token refresh request.
"""
__tablename__ = "jwt_blocklist"
id = db.Column(db.Integer, primary_key=True)
jti = db.Column(db.String(36), nullable=False, unique=True, index=True)
token_type = db.Column(db.String(20), nullable=False, default="refresh")
revoked_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<JWTBlocklist jti={self.jti}>"
+744
View File
@@ -0,0 +1,744 @@
"""
models/salon.py Tenant-level models (all carry tenant_id).
Soft delete convention: all tenant models include deleted_at.
Queries always filter WHERE deleted_at IS NULL.
"""
import logging
from datetime import datetime, timezone
from app.extensions import db
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────
# User & Location
# ─────────────────────────────────────────────────────────────
class User(db.Model):
"""Tenant admin and manager accounts (email + password login)."""
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
email = db.Column(db.String(255), nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(30), nullable=False)
# role: 'tenant_admin' | 'tenant_manager'
is_active = db.Column(db.Boolean, nullable=False, default=True)
failed_login_attempts = db.Column(db.Integer, nullable=False, default=0)
locked_until = db.Column(db.DateTime, nullable=True)
password_reset_token = db.Column(db.String(255), nullable=True)
password_reset_expires_at = db.Column(db.DateTime, nullable=True)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
# Flask-Login interface
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"user:{self.id}"
def is_locked(self):
if self.locked_until is None:
return False
return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc)
def record_login(self):
self.failed_login_attempts = 0
self.locked_until = None
self.last_login_at = datetime.now(timezone.utc)
def record_failed_login(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.failed_login_attempts = (self.failed_login_attempts or 0) + 1
if self.failed_login_attempts >= max_attempts:
self.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def __repr__(self):
return f"<User {self.email} tenant={self.tenant_id}>"
class TenantSetting(db.Model):
__tablename__ = "tenant_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "setting_key",
name="uq_tenant_settings_key"),
)
def __repr__(self):
return f"<TenantSetting {self.setting_key}={self.setting_value}>"
class Location(db.Model):
__tablename__ = "locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
address = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
timezone = db.Column(db.String(60), nullable=False, default="America/New_York")
is_active = db.Column(db.Boolean, nullable=False, default=True)
is_primary = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
settings = db.relationship("LocationSetting", back_populates="location",
lazy="dynamic")
def __repr__(self):
return f"<Location {self.name} tenant={self.tenant_id}>"
class LocationSetting(db.Model):
__tablename__ = "location_settings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
setting_key = db.Column(db.String(100), nullable=False)
setting_value = db.Column(db.Text, nullable=True)
location = db.relationship("Location", back_populates="settings")
__table_args__ = (
db.UniqueConstraint("location_id", "setting_key",
name="uq_location_settings_key"),
)
# ─────────────────────────────────────────────────────────────
# Customers
# ─────────────────────────────────────────────────────────────
class Customer(db.Model):
__tablename__ = "customers"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=True)
email = db.Column(db.String(255), nullable=True)
date_of_birth = db.Column(db.Date, nullable=True)
preferred_staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=True)
notes = db.Column(db.Text, nullable=True)
loyalty_points = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True)
no_show_count = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
deleted_at = db.Column(db.DateTime, nullable=True)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def __repr__(self):
return f"<Customer {self.name} tenant={self.tenant_id}>"
# ─────────────────────────────────────────────────────────────
# Services, Products & Promotions
# ─────────────────────────────────────────────────────────────
class Service(db.Model):
__tablename__ = "services"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
category = db.Column(db.String(100), nullable=True)
duration_min = db.Column(db.Integer, nullable=False, default=30)
price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Service {self.name}>"
class Product(db.Model):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def __repr__(self):
return f"<Product {self.name}>"
class Promotion(db.Model):
"""
applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all'
target_ids_json: list of IDs when applies_to is 'service' or 'product'; else null.
discount_percent: 1100 integer.
Active window: starts_at <= NOW() <= ends_at AND is_active = true.
"""
__tablename__ = "promotions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
discount_percent = db.Column(db.Integer, nullable=False)
applies_to = db.Column(db.String(30), nullable=False)
target_ids_json = db.Column(db.JSON, nullable=True)
starts_at = db.Column(db.DateTime, nullable=False)
ends_at = db.Column(db.DateTime, nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
def __repr__(self):
return f"<Promotion {self.name} {self.discount_percent}%>"
# ─────────────────────────────────────────────────────────────
# Staff
# ─────────────────────────────────────────────────────────────
class Staff(db.Model):
"""
staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist'
pay_type: 'hourly' | 'salary' | 'guarantee'
pay_period: 'weekly' | 'biweekly' | 'monthly'
passcode_hash: bcrypt-hashed 46 digit PIN; set by tenant_admin at creation.
phone: unique within tenant; used for staff-login.
"""
__tablename__ = "staff"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
name = db.Column(db.String(150), nullable=False)
phone = db.Column(db.String(30), nullable=False)
passcode_hash = db.Column(db.String(255), nullable=False)
staff_type = db.Column(db.String(30), nullable=False)
pay_type = db.Column(db.String(20), nullable=False, default="hourly")
hourly_rate = db.Column(db.Numeric(8, 2), nullable=True)
salary_amount = db.Column(db.Numeric(10, 2), nullable=True)
guarantee_amount = db.Column(db.Numeric(10, 2), nullable=True)
pay_period = db.Column(db.String(20), nullable=False, default="biweekly")
commission_rate = db.Column(db.Numeric(5, 2), nullable=True)
commission_enabled = db.Column(db.Boolean, nullable=False, default=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
passcode_failed_attempts = db.Column(db.Integer, nullable=False, default=0)
passcode_locked_until = db.Column(db.DateTime, nullable=True)
deleted_at = db.Column(db.DateTime, nullable=True)
locations = db.relationship("StaffLocation", back_populates="staff",
lazy="dynamic")
schedules = db.relationship("StaffSchedule", back_populates="staff",
lazy="dynamic")
__table_args__ = (
db.UniqueConstraint("tenant_id", "phone", name="uq_staff_tenant_phone"),
)
# Flask-Login interface (for staff portal session)
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return f"staff:{self.id}"
def is_passcode_locked(self):
if self.passcode_locked_until is None:
return False
return datetime.now(timezone.utc) < self.passcode_locked_until.replace(
tzinfo=timezone.utc
)
def record_passcode_success(self):
self.passcode_failed_attempts = 0
self.passcode_locked_until = None
def record_passcode_failure(self, max_attempts: int, lockout_minutes: int):
from datetime import timedelta
self.passcode_failed_attempts = (self.passcode_failed_attempts or 0) + 1
if self.passcode_failed_attempts >= max_attempts:
self.passcode_locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_minutes)
def soft_delete(self):
self.deleted_at = datetime.now(timezone.utc)
def is_assigned_to_location(self, location_id: int) -> bool:
return any(loc.id == location_id for loc in self.locations)
def __repr__(self):
return f"<Staff {self.name} tenant={self.tenant_id}>"
class StaffLocation(db.Model):
__tablename__ = "staff_locations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff = db.relationship("Staff", back_populates="locations")
location = db.relationship("Location")
__table_args__ = (
db.UniqueConstraint("staff_id", "location_id",
name="uq_staff_location"),
)
class StaffSchedule(db.Model):
__tablename__ = "staff_schedules"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
day_of_week = db.Column(db.Integer, nullable=False) # 0=Mon … 6=Sun
start_time = db.Column(db.Time, nullable=False)
end_time = db.Column(db.Time, nullable=False)
staff = db.relationship("Staff", back_populates="schedules")
# ─────────────────────────────────────────────────────────────
# Appointments
# ─────────────────────────────────────────────────────────────
class Appointment(db.Model):
"""
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk' | null
"""
__tablename__ = "appointments"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
start_time = db.Column(db.DateTime, nullable=False)
end_time = db.Column(db.DateTime, nullable=True)
is_walk_in = db.Column(db.Boolean, nullable=False, default=False)
status = db.Column(db.String(20), nullable=False, default="pending")
notes = db.Column(db.Text, nullable=True)
cancellation_reason = db.Column(db.Text, nullable=True)
cancelled_at = db.Column(db.DateTime, nullable=True)
rebook_source = db.Column(db.String(20), nullable=True)
rebooked_from_transaction_id = db.Column(
db.Integer, db.ForeignKey("transactions.id"), nullable=True
)
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
customer = db.relationship("Customer")
staff = db.relationship("Staff")
service = db.relationship("Service")
def __repr__(self):
return f"<Appointment {self.id} status={self.status}>"
# ─────────────────────────────────────────────────────────────
# Transactions & POS
# ─────────────────────────────────────────────────────────────
class Transaction(db.Model):
"""
payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other'
"""
__tablename__ = "transactions"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
subtotal = db.Column(db.Numeric(10, 2), nullable=False)
discount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
tip_amount = db.Column(db.Numeric(8, 2), nullable=False, default=0)
gift_card_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total = db.Column(db.Numeric(10, 2), nullable=False)
payment_method = db.Column(db.String(20), nullable=False)
payment_reference = db.Column(db.String(255), nullable=True)
gift_card_id = db.Column(db.Integer, db.ForeignKey("gift_cards.id"),
nullable=True)
review_request_sent_at = db.Column(db.DateTime, nullable=True)
voided_at = db.Column(db.DateTime, nullable=True)
voided_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
void_reason = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
items = db.relationship("TransactionItem", back_populates="transaction",
lazy="dynamic")
def __repr__(self):
return f"<Transaction {self.id} total={self.total}>"
class TransactionItem(db.Model):
__tablename__ = "transaction_items"
id = db.Column(db.Integer, primary_key=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False, index=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=True)
qty = db.Column(db.Integer, nullable=False, default=1)
unit_price = db.Column(db.Numeric(8, 2), nullable=False)
# unit_price: final price after promotion
original_price = db.Column(db.Numeric(8, 2), nullable=False)
# original_price: price before promotion
discount_percent = db.Column(db.Numeric(5, 2), nullable=False, default=0)
promotion_id = db.Column(db.Integer, db.ForeignKey("promotions.id"),
nullable=True)
transaction = db.relationship("Transaction", back_populates="items")
def __repr__(self):
return f"<TransactionItem tx={self.transaction_id}>"
# ─────────────────────────────────────────────────────────────
# Inventory
# ─────────────────────────────────────────────────────────────
class Inventory(db.Model):
__tablename__ = "inventory"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
sku = db.Column(db.String(100), nullable=True)
category = db.Column(db.String(100), nullable=True)
qty_on_hand = db.Column(db.Integer, nullable=False, default=0)
reorder_level = db.Column(db.Integer, nullable=False, default=5)
cost_price = db.Column(db.Numeric(8, 2), nullable=True)
sale_price = db.Column(db.Numeric(8, 2), nullable=True)
def __repr__(self):
return f"<Inventory {self.name} qty={self.qty_on_hand}>"
class InventoryLog(db.Model):
__tablename__ = "inventory_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
inventory_id = db.Column(db.Integer, db.ForeignKey("inventory.id"),
nullable=False)
delta = db.Column(db.Integer, nullable=False)
reason = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Commission & Pay
# ─────────────────────────────────────────────────────────────
class CommissionLog(db.Model):
__tablename__ = "commission_log"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
period = db.Column(db.String(20), nullable=True)
class StaffPayPeriod(db.Model):
"""
status: 'draft' | 'approved' | 'paid'
pay_type: mirrors staff.pay_type at the time of the period.
guarantee_topup: max(0, guarantee_amount - commission_amount) for 'guarantee' pay_type.
"""
__tablename__ = "staff_pay_periods"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
period_start = db.Column(db.Date, nullable=False)
period_end = db.Column(db.Date, nullable=False)
pay_type = db.Column(db.String(20), nullable=False)
base_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
commission_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
guarantee_topup = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
status = db.Column(db.String(20), nullable=False, default="draft")
notes = db.Column(db.Text, nullable=True)
class StaffClocking(db.Model):
"""One row per shift. clocked_out_at NULL = currently clocked in."""
__tablename__ = "staff_clockings"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"),
nullable=False, index=True)
clocked_in_at = db.Column(db.DateTime, nullable=False)
clocked_out_at = db.Column(db.DateTime, nullable=True)
total_minutes = db.Column(db.Integer, nullable=True)
notes = db.Column(db.Text, nullable=True)
# ─────────────────────────────────────────────────────────────
# Marketing
# ─────────────────────────────────────────────────────────────
class MarketingCampaign(db.Model):
"""
channel: 'email' (SMS deferred)
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'
"""
__tablename__ = "marketing_campaigns"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
name = db.Column(db.String(150), nullable=False)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="draft")
audience_filter_json = db.Column(db.JSON, nullable=True)
subject = db.Column(db.String(255), nullable=True)
message_body = db.Column(db.Text, nullable=True)
scheduled_at = db.Column(db.DateTime, nullable=True)
sent_at = db.Column(db.DateTime, nullable=True)
sent_count = db.Column(db.Integer, nullable=False, default=0)
open_count = db.Column(db.Integer, nullable=False, default=0)
# ─────────────────────────────────────────────────────────────
# Gift Cards
# ─────────────────────────────────────────────────────────────
class GiftCard(db.Model):
__tablename__ = "gift_cards"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
code = db.Column(db.String(50), nullable=False)
original_value = db.Column(db.Numeric(10, 2), nullable=False)
remaining_balance = db.Column(db.Numeric(10, 2), nullable=False)
issued_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
issued_to_customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.UniqueConstraint("tenant_id", "code", name="uq_gift_card_tenant_code"),
)
# ─────────────────────────────────────────────────────────────
# Check-in Kiosk & Waitlist
# ─────────────────────────────────────────────────────────────
class CheckinQueue(db.Model):
"""
status: 'waiting' | 'acknowledged' | 'seated' | 'expired'
Surfaced to receptionist dashboard via 5-second polling.
"""
__tablename__ = "checkin_queue"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=False)
service_requested = db.Column(db.String(150), nullable=True)
checked_in_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
status = db.Column(db.String(20), nullable=False, default="waiting")
acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"),
nullable=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
class Waitlist(db.Model):
"""status: 'waiting' | 'notified' | 'booked' | 'expired'"""
__tablename__ = "waitlist"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
customer_name = db.Column(db.String(150), nullable=False)
customer_phone = db.Column(db.String(30), nullable=True)
customer_email = db.Column(db.String(255), nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
service_id = db.Column(db.Integer, db.ForeignKey("services.id"), nullable=True)
requested_date = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), nullable=False, default="waiting")
notified_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
# ─────────────────────────────────────────────────────────────
# Reviews & Reconciliation
# ─────────────────────────────────────────────────────────────
class CheckoutReview(db.Model):
"""
rating: 15 integer.
is_public_suggested: True if rating >= 4 (platform links shown).
"""
__tablename__ = "checkout_reviews"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
transaction_id = db.Column(db.Integer, db.ForeignKey("transactions.id"),
nullable=False)
customer_id = db.Column(db.Integer, db.ForeignKey("customers.id"),
nullable=True)
staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), nullable=True)
rating = db.Column(db.Integer, nullable=False)
comment = db.Column(db.Text, nullable=True)
is_public_suggested = db.Column(db.Boolean, nullable=False, default=False)
google_clicked = db.Column(db.Boolean, nullable=False, default=False)
facebook_clicked = db.Column(db.Boolean, nullable=False, default=False)
yelp_clicked = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False,
default=lambda: datetime.now(timezone.utc))
class DailyReconciliation(db.Model):
"""variance = actual_cash_counted - expected_cash_in_drawer (negative = shortage)"""
__tablename__ = "daily_reconciliations"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
date = db.Column(db.Date, nullable=False)
total_cash = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_app_payments = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_tips = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_gift_card_redemptions = db.Column(db.Numeric(10, 2), nullable=False,
default=0)
expected_cash_in_drawer = db.Column(db.Numeric(10, 2), nullable=False, default=0)
actual_cash_counted = db.Column(db.Numeric(10, 2), nullable=True)
variance = db.Column(db.Numeric(10, 2), nullable=True)
closed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
closed_at = db.Column(db.DateTime, nullable=True)
notes = db.Column(db.Text, nullable=True)
__table_args__ = (
db.UniqueConstraint("tenant_id", "location_id", "date",
name="uq_reconciliation_location_date"),
)
class AppointmentReminder(db.Model):
"""
reminder_type: '24h' | '2h'
channel: 'email' (SMS deferred)
status: 'pending' | 'sent' | 'failed' | 'cancelled'
"""
__tablename__ = "appointment_reminders"
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"),
nullable=False, index=True)
location_id = db.Column(db.Integer, db.ForeignKey("locations.id"),
nullable=False, index=True)
appointment_id = db.Column(db.Integer, db.ForeignKey("appointments.id"),
nullable=False, index=True)
reminder_type = db.Column(db.String(10), nullable=False)
scheduled_for = db.Column(db.DateTime, nullable=False)
sent_at = db.Column(db.DateTime, nullable=True)
channel = db.Column(db.String(20), nullable=False, default="email")
status = db.Column(db.String(20), nullable=False, default="pending")
+99
View File
@@ -0,0 +1,99 @@
"""
security.py Security middleware and input sanitisation helpers.
- apply_security_headers(app) Attach security headers to every response.
- check_admin_ip(app) Enforce ADMIN_IP_ALLOWLIST at the Flask layer.
- sanitise_string(value) Strip control characters from user input.
- validate_slug(slug) Validate tenant slug format.
"""
import re
import logging
import ipaddress
from flask import request, abort, current_app
logger = logging.getLogger(__name__)
# Regex: slugs must be lowercase alphanumeric + hyphens, 280 chars
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,78}[a-z0-9]$")
# Strip ASCII control characters (0x000x1F, 0x7F) and Unicode C0/C1 blocks
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f\x80-\x9f]")
def apply_security_headers(app):
"""
Register an after_request hook that attaches security headers to every
response. Nginx adds HSTS and the more restrictive CSP for production;
this layer provides defence-in-depth and covers the development server.
"""
@app.after_request
def _add_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Basic CSP — tightened further in Nginx for production
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none';"
)
return response
def check_admin_ip(app):
"""
Register a before_request hook on the admin app that validates the
client IP against ADMIN_IP_ALLOWLIST. Provides a Flask-layer double-check
behind Nginx's allow/deny directives.
"""
@app.before_request
def _check_ip():
allowlist_raw = app.config.get("ADMIN_IP_ALLOWLIST", "")
if not allowlist_raw.strip():
return # No allowlist configured — skip check (dev mode)
networks = []
for cidr in allowlist_raw.split(","):
cidr = cidr.strip()
if cidr:
try:
networks.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
logger.error("Invalid CIDR in ADMIN_IP_ALLOWLIST: %s", cidr)
if not networks:
return
client_ip_str = request.headers.get("X-Real-IP") or request.remote_addr
try:
client_ip = ipaddress.ip_address(client_ip_str)
except ValueError:
logger.warning("Unparseable client IP: %s", client_ip_str)
abort(403)
return
if not any(client_ip in net for net in networks):
logger.warning(
"Admin IP blocked: %s not in allowlist", client_ip_str
)
abort(403)
def sanitise_string(value: str, max_length: int = None) -> str:
"""
Strip control characters from a user-supplied string.
Optionally truncate to max_length.
"""
if not isinstance(value, str):
return value
cleaned = _CONTROL_CHAR_RE.sub("", value).strip()
if max_length:
cleaned = cleaned[:max_length]
return cleaned
def validate_slug(slug: str) -> bool:
"""Return True if the slug matches the allowed pattern."""
return bool(_SLUG_RE.match(slug))
+2
View File
@@ -0,0 +1,2 @@
/* Admin portal custom styles — extended in later phases */
body { background-color: #f4f6f9; }
+2
View File
@@ -0,0 +1,2 @@
/* Tenant portal custom styles — extended in later phases */
body { background-color: #f8f9fa; }
+28
View File
@@ -0,0 +1,28 @@
{% extends "admin/base.html" %}
{% block title %}Admin Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Admin Portal</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('admin_auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-dark w-100">Sign In</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Admin Portal{% endblock %} — Nails Salon POS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='admin/css/main.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand">Salon POS Admin</span>
<div class="d-flex align-items-center gap-3">
<span class="text-light small">{{ current_user.name }}</span>
<a href="{{ url_for('admin_auth.logout') }}" class="btn btn-outline-light btn-sm">Logout</a>
</div>
</div>
</nav>
{% endif %}
<main class="container py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "tenant/base.html" %}
{% block title %}Account Cancelled{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 text-center">
<h3 class="text-danger">Account Cancelled</h3>
<p class="text-muted">This account has been cancelled. Please contact support if you believe this is an error.</p>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "tenant/base.html" %}
{% block title %}Sign In{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Salon Login</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<hr>
<div class="text-center small">
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
&nbsp;|&nbsp;
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "tenant/base.html" %}
{% block title %}Set New Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h5 class="card-title mb-3">Set New Password</h5>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" name="password" class="form-control" required minlength="10">
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Update Password</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tenant/base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h5 class="card-title mb-3">Reset Password</h5>
{% if message %}
<div class="alert alert-info">{{ message }}</div>
{% else %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Email address</label>
<input type="email" name="email" class="form-control" required autofocus>
</div>
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
</form>
{% endif %}
<div class="text-center mt-3 small">
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "tenant/base.html" %}
{% block title %}Account Suspended{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 text-center">
<h3 class="text-warning">Account Suspended</h3>
<p class="text-muted">Your salon account has been suspended. Please contact support to resolve your billing.</p>
<a href="{{ url_for('tenant_auth.login') }}" class="btn btn-outline-secondary mt-2">Back to Login</a>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Salon Portal{% endblock %} — Nails Salon POS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='tenant/css/main.css') }}">
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-light bg-white border-bottom shadow-sm">
<div class="container-fluid">
<span class="navbar-brand fw-bold">
{% if g.tenant %}{{ g.tenant.name }}{% else %}Salon POS{% endif %}
</span>
{% if g.location %}
<span class="badge bg-secondary">{{ g.location.name }}</span>
{% endif %}
<div class="d-flex align-items-center gap-3">
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary btn-sm">Logout</a>
</div>
</div>
</nav>
{% endif %}
<main class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to {{ tenant.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
body { background: #f8f9fa; }
.kiosk-card { max-width: 540px; margin: 60px auto; }
.kiosk-title { font-size: 2rem; font-weight: 700; }
</style>
</head>
<body>
<div class="kiosk-card">
{% if confirmed %}
<div class="card shadow text-center p-5" id="confirmation">
<div class="display-1 mb-3"></div>
<h2 class="kiosk-title">You're checked in!</h2>
<p class="text-muted mt-2">A staff member will be right with you.</p>
<p class="text-muted small" id="reset-countdown">Resetting in <span id="countdown">10</span>s…</p>
</div>
<script>
let n = 10;
const el = document.getElementById("countdown");
const timer = setInterval(() => {
n--;
el.textContent = n;
if (n <= 0) { clearInterval(timer); window.location.reload(); }
}, 1000);
</script>
{% else %}
<div class="card shadow p-4">
<h2 class="kiosk-title text-center mb-4">Welcome to {{ tenant.name }}</h2>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="mb-3">
<label class="form-label fs-5">Your Name</label>
<input type="text" name="customer_name" class="form-control form-control-lg"
placeholder="First and last name" required autofocus>
</div>
<div class="mb-3">
<label class="form-label fs-5">Phone Number</label>
<input type="tel" name="customer_phone" class="form-control form-control-lg"
placeholder="e.g. 5551234567" inputmode="numeric" required>
</div>
{% if services %}
<div class="mb-3">
<label class="form-label fs-5">Service (optional)</label>
<select name="service_requested" class="form-select form-select-lg">
<option value="">— Select a service —</option>
{% for svc in services %}
<option value="{{ svc.name }}">{{ svc.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<button type="submit" class="btn btn-primary btn-lg w-100 mt-2">Check In</button>
</form>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{% extends "tenant/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h4 class="mb-4">Dashboard
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
</h4>
<div class="alert alert-info">
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "tenant/base.html" %}
{% block title %}Staff Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">Staff Login</h4>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('staff_auth.staff_login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="tel" name="phone" class="form-control form-control-lg"
placeholder="e.g. 5551234567" required autofocus inputmode="numeric">
</div>
<div class="mb-3">
<label class="form-label">Passcode</label>
<input type="password" name="passcode" class="form-control form-control-lg"
placeholder="46 digit PIN" maxlength="6" inputmode="numeric" required>
</div>
<button type="submit" class="btn btn-success w-100 btn-lg">Clock In</button>
</form>
<div class="text-center mt-3 small">
<a href="{{ url_for('tenant_auth.login') }}">Manager / Owner login</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
"""
app/tenant/__init__.py Tenant app factory (mydomain.com).
"""
import logging
from datetime import timedelta
import os
from flask import Flask
from app.extensions import (
db, migrate, csrf, mail, scheduler,
tenant_login_manager, tenant_jwt, limiter,
)
from app.security import apply_security_headers
from app.context import load_tenant_context, load_location_context
from config import get_config
logger = logging.getLogger(__name__)
def create_tenant_app(config_override=None):
flask_app = Flask(
__name__,
template_folder="../templates",
static_folder="../static",
static_url_path="/static/tenant",
)
# ── Config ────────────────────────────────────────────────
cfg = config_override or get_config()
flask_app.config.from_object(cfg)
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_tenant_session"
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
seconds=int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800))
)
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# ── Extensions ────────────────────────────────────────────
db.init_app(flask_app)
migrate.init_app(flask_app, db)
csrf.init_app(flask_app)
mail.init_app(flask_app)
limiter.init_app(flask_app)
# Tenant login manager
tenant_login_manager.login_view = "tenant_auth.login"
tenant_login_manager.login_message_category = "warning"
tenant_login_manager.session_protection = "strong"
tenant_login_manager.init_app(flask_app)
# Tenant JWT
tenant_jwt.init_app(flask_app)
# ── User loaders ──────────────────────────────────────────
from app.models.salon import User, Staff
@tenant_login_manager.user_loader
def load_tenant_user(user_id: str):
if user_id.startswith("user:"):
try:
uid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return User.query.filter_by(id=uid, is_active=True)\
.filter(User.deleted_at.is_(None)).first()
if user_id.startswith("staff:"):
try:
sid = int(user_id.split(":")[1])
except (ValueError, IndexError):
return None
return Staff.query.filter_by(id=sid, is_active=True)\
.filter(Staff.deleted_at.is_(None)).first()
return None
# ── Context hooks ─────────────────────────────────────────
flask_app.before_request(load_tenant_context)
flask_app.before_request(load_location_context)
# ── Security ──────────────────────────────────────────────
apply_security_headers(flask_app)
# ── Blueprints ────────────────────────────────────────────
from app.tenant.auth.routes import tenant_auth_bp
from app.tenant.staff_auth.routes import staff_auth_bp
from app.tenant.checkin.routes import checkin_bp
from app.tenant.dashboard.routes import dashboard_bp
flask_app.register_blueprint(tenant_auth_bp)
flask_app.register_blueprint(staff_auth_bp)
flask_app.register_blueprint(checkin_bp)
flask_app.register_blueprint(dashboard_bp)
# Remaining blueprints registered in Phases 36:
# locations, customers, appointments, services, pos, staff,
# staff_portal, booking, waitlist, gift_cards, reviews,
# reconciliation, inventory, marketing, reports, settings
# ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401
logger.info("Tenant app created (env=%s)", flask_app.config.get("FLASK_ENV"))
return flask_app
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/appointments/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
View File
+198
View File
@@ -0,0 +1,198 @@
"""
app/tenant/auth/routes.py Tenant portal authentication (email + password).
Routes: /login, /logout, /password-reset, /suspended, /cancelled
Roles: tenant_admin, tenant_manager
"""
import logging
import secrets
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
flash, request, current_app, session, g,
)
from flask_login import login_user, logout_user, login_required, current_user
import bcrypt
from app.extensions import db, limiter, mail
from app.models.platform import Tenant
from app.models.salon import User
from flask_mail import Message
logger = logging.getLogger(__name__)
tenant_auth_bp = Blueprint("tenant_auth", __name__)
@tenant_auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def login():
if current_user.is_authenticated:
return redirect(url_for("dashboard.index"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = User.query.filter_by(email=email, is_active=True)\
.filter(User.deleted_at.is_(None)).first()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
if user and user.is_locked():
logger.warning("Tenant login blocked — account locked: %s", email)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
# Verify tenant is accessible
tenant = Tenant.query.get(user.tenant_id)
if not tenant or tenant.status == "cancelled":
error = "This account is no longer active."
else:
user.failed_login_attempts = 0
user.locked_until = None
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user, remember=False)
session.permanent = True
logger.info(
"Tenant login success: user=%s tenant=%s",
user.id, user.tenant_id,
)
return redirect(url_for("dashboard.index"))
else:
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= max_attempts:
user.locked_until = datetime.now(timezone.utc) + timedelta(
minutes=lockout_minutes
)
logger.warning(
"Tenant account locked after %d failures: %s",
max_attempts, email,
)
db.session.commit()
logger.warning("Tenant login failed: %s", email)
error = "Invalid email or password."
return render_template("tenant/auth/login.html", error=error)
@tenant_auth_bp.route("/logout")
@login_required
def logout():
logout_user()
session.clear()
logger.info("Tenant logout")
return redirect(url_for("tenant_auth.login"))
@tenant_auth_bp.route("/password-reset", methods=["GET", "POST"])
@limiter.limit("5 per hour")
def password_reset_request():
"""Step 1: submit email → send reset link."""
message = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = User.query.filter_by(email=email)\
.filter(User.deleted_at.is_(None)).first()
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
db.session.commit()
reset_url = url_for(
"tenant_auth.password_reset_confirm",
token=token,
_external=True,
)
try:
msg = Message(
subject="Reset your password",
recipients=[email],
body=f"Click the link below to reset your password (valid 1 hour):\n\n{reset_url}",
)
mail.send(msg)
logger.info("Password reset email sent to %s", email)
except Exception as exc:
logger.error("Failed to send reset email to %s: %s", email, exc)
# Always show the same message to prevent email enumeration
message = "If that email is registered, a reset link has been sent."
return render_template(
"tenant/auth/password_reset_request.html", message=message
)
@tenant_auth_bp.route("/password-reset/<token>", methods=["GET", "POST"])
def password_reset_confirm(token):
"""Step 2: confirm token → set new password."""
user = User.query.filter_by(password_reset_token=token)\
.filter(User.deleted_at.is_(None)).first()
now = datetime.now(timezone.utc)
if not user or not user.password_reset_expires_at:
flash("Invalid or expired reset link.", "danger")
return redirect(url_for("tenant_auth.login"))
expires = user.password_reset_expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if now > expires:
flash("This reset link has expired. Please request a new one.", "danger")
return redirect(url_for("tenant_auth.password_reset_request"))
error = None
if request.method == "POST":
new_password = request.form.get("password", "")
confirm = request.form.get("confirm_password", "")
if new_password != confirm:
error = "Passwords do not match."
elif len(new_password) < 10:
error = "Password must be at least 10 characters."
elif not any(c.isupper() for c in new_password):
error = "Password must contain at least one uppercase letter."
elif not any(c.islower() for c in new_password):
error = "Password must contain at least one lowercase letter."
elif not any(c.isdigit() for c in new_password):
error = "Password must contain at least one digit."
else:
hashed = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12))
user.password_hash = hashed.decode()
user.password_reset_token = None
user.password_reset_expires_at = None
user.failed_login_attempts = 0
user.locked_until = None
db.session.commit()
logger.info("Password reset completed for user %s", user.id)
flash("Password updated. Please log in.", "success")
return redirect(url_for("tenant_auth.login"))
return render_template(
"tenant/auth/password_reset_confirm.html", error=error, token=token
)
@tenant_auth_bp.route("/suspended")
def suspended():
return render_template("tenant/auth/suspended.html"), 403
@tenant_auth_bp.route("/cancelled")
def cancelled():
return render_template("tenant/auth/cancelled.html"), 403
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/booking/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
booking_bp = Blueprint("booking", __name__, url_prefix="")
View File
+135
View File
@@ -0,0 +1,135 @@
"""
app/tenant/checkin/routes.py Customer self check-in kiosk.
Route: GET/POST /checkin/<tenant_slug>
No authentication required. CSRF-exempt. Rate-limited.
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting.
"""
import logging
from datetime import datetime, timezone
from flask import (
Blueprint, render_template, request, jsonify,
current_app, abort,
)
from app.extensions import db, limiter, csrf
from app.models.platform import Tenant
from app.models.salon import Customer, CheckinQueue, Location
from app.security import sanitise_string, validate_slug
logger = logging.getLogger(__name__)
checkin_bp = Blueprint("checkin", __name__)
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
@csrf.exempt
@limiter.limit("20 per minute")
def kiosk(tenant_slug: str):
"""
Public kiosk page. No auth required.
Tenant slug validated against known slugs (not guessable).
Accepts: name, phone, service_requested all other fields ignored.
Phone is sanitised and validated before profile lookup.
Page auto-resets after 10 seconds (configurable per tenant) via JS.
"""
# Validate slug format before hitting the DB
if not validate_slug(tenant_slug):
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
abort(404)
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first()
if not tenant or not tenant.is_active_status():
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
abort(404)
# Resolve the primary location for this tenant
location = Location.query.filter_by(
tenant_id=tenant.id,
is_primary=True,
is_active=True,
).filter(Location.deleted_at.is_(None)).first()
if location is None:
abort(404)
confirmed = False
if request.method == "POST":
raw_name = request.form.get("customer_name", "")
raw_phone = request.form.get("customer_phone", "")
raw_service = request.form.get("service_requested", "")
name = sanitise_string(raw_name, max_length=150)
phone = sanitise_string(raw_phone, max_length=30)
service = sanitise_string(raw_service, max_length=150)
# Validate required fields
if not name or not phone:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Name and phone number are required.",
confirmed=False,
)
# Strip non-digit characters from phone for lookup consistency
phone_digits = "".join(filter(str.isdigit, phone))
if len(phone_digits) < 7:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Please enter a valid phone number.",
confirmed=False,
)
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id,
phone=phone_digits,
).filter(Customer.deleted_at.is_(None)).first()
if customer is None:
customer = Customer(
tenant_id=tenant.id,
name=name,
phone=phone_digits,
)
db.session.add(customer)
db.session.flush() # get customer.id before committing
logger.info(
"Kiosk: new customer profile created for tenant %s", tenant.slug
)
# Queue the walk-in entry
entry = CheckinQueue(
tenant_id=tenant.id,
location_id=location.id,
customer_id=customer.id,
customer_name=name,
customer_phone=phone_digits,
service_requested=service or None,
status="waiting",
)
db.session.add(entry)
db.session.commit()
logger.info(
"Kiosk: check-in queued for tenant=%s location=%s customer=%s",
tenant.slug, location.id, customer.id,
)
confirmed = True
# Fetch services for the kiosk selector (if configured)
from app.models.salon import Service
services = Service.query.filter_by(
tenant_id=tenant.id,
is_active=True,
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
services=services,
confirmed=confirmed,
error=None,
)
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/customers/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
View File
+23
View File
@@ -0,0 +1,23 @@
"""
app/tenant/dashboard/routes.py Tenant dashboard (placeholder for Phase 3 KPIs).
"""
import logging
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
logger = logging.getLogger(__name__)
dashboard_bp = Blueprint("dashboard", __name__)
@dashboard_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template(
"tenant/dashboard/index.html",
tenant=g.tenant,
location=g.location,
)
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/gift_cards/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/inventory/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/locations/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/marketing/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/pos/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reconciliation/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reports/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/reviews/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/services/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
services_bp = Blueprint("services", __name__, url_prefix="/services")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/settings/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/staff/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
View File
+113
View File
@@ -0,0 +1,113 @@
"""
app/tenant/staff_auth/routes.py Staff portal authentication (phone + passcode).
Routes: /staff-login, /staff-logout
Brute-force: 5 failures passcode_locked_until for LOGIN_LOCKOUT_MINUTES.
"""
import logging
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
request, current_app, session, flash,
)
from flask_login import login_user, logout_user, login_required, current_user
import bcrypt
from app.extensions import db, limiter
from app.models.salon import Staff
from app.models.platform import Tenant
logger = logging.getLogger(__name__)
staff_auth_bp = Blueprint("staff_auth", __name__)
@staff_auth_bp.route("/staff-login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def staff_login():
if current_user.is_authenticated:
return redirect(url_for("staff_portal.index"))
error = None
if request.method == "POST":
phone = request.form.get("phone", "").strip()
passcode = request.form.get("passcode", "").strip()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
min_len = current_app.config.get("STAFF_PASSCODE_MIN_LENGTH", 4)
max_len = current_app.config.get("STAFF_PASSCODE_MAX_LENGTH", 6)
# Basic input validation
if not phone or not passcode:
error = "Phone number and passcode are required."
elif not passcode.isdigit() or not (min_len <= len(passcode) <= max_len):
error = f"Passcode must be {min_len}{max_len} digits."
else:
# Look up staff by phone (phone is unique within tenant)
# Platform-wide: find first matching phone, then validate tenant
staff = Staff.query.filter_by(phone=phone, is_active=True)\
.filter(Staff.deleted_at.is_(None)).first()
if staff and staff.is_passcode_locked():
logger.warning(
"Staff passcode login blocked — locked: staff=%s", staff.id
)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif staff and bcrypt.checkpw(
passcode.encode(), staff.passcode_hash.encode()
):
# Verify the tenant is active
tenant = Tenant.query.get(staff.tenant_id)
if not tenant or not tenant.is_active_status():
error = "Your salon account is not active."
else:
# Success
staff.passcode_failed_attempts = 0
staff.passcode_locked_until = None
db.session.commit()
login_user(staff, remember=False)
session.permanent = True
# Set active location to staff's primary assigned location
from app.models.salon import StaffLocation
assignment = StaffLocation.query.filter_by(
staff_id=staff.id,
tenant_id=staff.tenant_id,
).first()
if assignment:
session["active_location_id"] = assignment.location_id
logger.info(
"Staff login success: staff=%s tenant=%s",
staff.id, staff.tenant_id,
)
return redirect(url_for("staff_portal.index"))
else:
if staff:
staff.passcode_failed_attempts += 1
if staff.passcode_failed_attempts >= max_attempts:
staff.passcode_locked_until = datetime.now(timezone.utc) + \
timedelta(minutes=lockout_minutes)
logger.warning(
"Staff passcode locked after %d failures: staff=%s",
max_attempts, staff.id,
)
db.session.commit()
logger.warning("Staff login failed for phone: %s", phone)
error = "Invalid phone number or passcode."
return render_template("tenant/staff_auth/login.html", error=error)
@staff_auth_bp.route("/staff-logout")
@login_required
def staff_logout():
logout_user()
session.clear()
logger.info("Staff logout")
return redirect(url_for("staff_auth.staff_login"))
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/staff_portal/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
View File
+7
View File
@@ -0,0 +1,7 @@
"""
app/tenant/waitlist/routes.py
Phase 3+ implementation.
"""
from flask import Blueprint
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
+98
View File
@@ -0,0 +1,98 @@
"""
config.py
Configuration classes for Development, Production, and Testing.
Loaded by both app factories via: from config import config
"""
import os
from datetime import timedelta
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-fallback-secret-change-me")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 280, "pool_pre_ping": True}
MAIL_SERVER = os.environ.get("MAIL_SERVER", "localhost")
MAIL_PORT = int(os.environ.get("MAIL_PORT", 587))
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", "true").lower() == "true"
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_USERNAME", "noreply@mydomain.com")
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRES", 900)))
JWT_REFRESH_TOKEN_EXPIRES = timedelta(seconds=int(os.environ.get("JWT_REFRESH_TOKEN_EXPIRES", 604800)))
JWT_TOKEN_LOCATION = ["cookies"]
JWT_COOKIE_SECURE = True
JWT_COOKIE_SAMESITE = "Lax"
JWT_COOKIE_CSRF_PROTECT = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://")
RATELIMIT_DEFAULT = "200 per minute"
RATELIMIT_ENABLED = True
ADMIN_DOMAIN = os.environ.get("ADMIN_DOMAIN", "admin.mydomain.com")
TENANT_DOMAIN = os.environ.get("TENANT_DOMAIN", "mydomain.com")
DEMO_TENANT_SLUG = os.environ.get("DEMO_TENANT_SLUG", "demo")
ADMIN_IP_ALLOWLIST = os.environ.get("ADMIN_IP_ALLOWLIST", "")
MAX_LOGIN_ATTEMPTS = int(os.environ.get("MAX_LOGIN_ATTEMPTS", 5))
LOGIN_LOCKOUT_MINUTES = int(os.environ.get("LOGIN_LOCKOUT_MINUTES", 15))
STAFF_PASSCODE_MIN_LENGTH = int(os.environ.get("STAFF_PASSCODE_MIN_LENGTH", 4))
STAFF_PASSCODE_MAX_LENGTH = int(os.environ.get("STAFF_PASSCODE_MAX_LENGTH", 6))
MAX_CONTENT_LENGTH = 5 * 1024 * 1024
UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif"}
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "/var/www/salon_pos_uploads")
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL", "mysql+pymysql://root:password@localhost/salon_pos_dev"
)
SESSION_COOKIE_SECURE = False
JWT_COOKIE_SECURE = False
RATELIMIT_ENABLED = False
SESSION_TIMEOUT_TENANT = 1800
SESSION_TIMEOUT_ADMIN = 3600
class ProductionConfig(Config):
DEBUG = False
TESTING = False
SESSION_TIMEOUT_TENANT = int(os.environ.get("SESSION_TIMEOUT_TENANT", 1800))
SESSION_TIMEOUT_ADMIN = int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
class TestingConfig(Config):
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False
JWT_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
RATELIMIT_ENABLED = False
SESSION_TIMEOUT_TENANT = 1800
SESSION_TIMEOUT_ADMIN = 3600
BCRYPT_LOG_ROUNDS = 4
config = {
"development": DevelopmentConfig,
"production": ProductionConfig,
"testing": TestingConfig,
"default": DevelopmentConfig,
}
def get_config():
"""Return the config class for the current FLASK_ENV. Used by app factories."""
import os
env = os.environ.get("FLASK_ENV", "development")
return config.get(env, DevelopmentConfig)
+1
View File
@@ -0,0 +1 @@
0 2 * * * salonpos /opt/salon_pos/deploy/backup/db_backup.sh >> /var/log/salon_pos_backup.log 2>&1
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="${BACKUP_DIR:-/var/backups/salon_pos}"
DB_NAME="salon_pos"
RETAIN_DAYS="${BACKUP_RETAIN_DAYS:-30}"
mkdir -p "$BACKUP_DIR"
mysqldump --defaults-file=/etc/mysql/backup.cnf "$DB_NAME" \
| gzip > "$BACKUP_DIR/salon_pos_$TIMESTAMP.sql.gz"
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETAIN_DAYS" -delete
echo "[$(date)] Backup completed: salon_pos_$TIMESTAMP.sql.gz"
+69
View File
@@ -0,0 +1,69 @@
# ── Admin portal ────────────────────────────────────────────────
server {
listen 443 ssl;
server_name admin.mydomain.com;
ssl_certificate /etc/ssl/certs/mydomain.crt;
ssl_certificate_key /etc/ssl/private/mydomain.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
allow 203.0.113.0/24;
deny all;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / {
proxy_pass http://unix:/run/salon_pos_admin.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m;
}
location /static/admin/ {
alias /opt/salon_pos/app/static/admin/;
expires 7d;
}
}
# ── Tenant portal ────────────────────────────────────────────────
server {
listen 443 ssl;
server_name mydomain.com;
ssl_certificate /etc/ssl/certs/mydomain.crt;
ssl_certificate_key /etc/ssl/private/mydomain.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always;
location / {
proxy_pass http://unix:/run/salon_pos_tenant.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m;
}
location /static/tenant/ {
alias /opt/salon_pos/app/static/tenant/;
expires 7d;
}
}
server {
listen 80;
server_name admin.mydomain.com mydomain.com;
return 301 https://$host$request_uri;
}
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Nails Salon POS — Admin Portal (Gunicorn)
After=network.target mysql.service
[Service]
User=salonpos
WorkingDirectory=/opt/salon_pos
EnvironmentFile=/opt/salon_pos/.env
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
--workers 2 \
--bind unix:/run/salon_pos_admin.sock \
--timeout 120 \
wsgi_admin:app
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Nails Salon POS — Tenant Portal (Gunicorn)
After=network.target mysql.service
[Service]
User=salonpos
WorkingDirectory=/opt/salon_pos
EnvironmentFile=/opt/salon_pos/.env
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
--workers 4 \
--bind unix:/run/salon_pos_tenant.sock \
--timeout 120 \
wsgi_tenant:app
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
+19
View File
@@ -0,0 +1,19 @@
Flask==3.0.3
Flask-Login==0.6.3
Flask-JWT-Extended==4.6.0
Flask-Migrate==4.0.7
Flask-WTF==1.2.1
Flask-Limiter==3.7.0
Flask-Mail==0.10.0
Flask-APScheduler==1.13.1
SQLAlchemy==2.0.30
Flask-SQLAlchemy==3.1.1
PyMySQL==1.1.1
bcrypt==4.1.3
python-dotenv==1.0.1
WeasyPrint==62.3
WTForms==3.1.2
gunicorn==22.0.0
cryptography==42.0.8
pytest==8.2.2
pytest-flask==1.3.0
+2
View File
@@ -0,0 +1,2 @@
/* Admin portal custom styles */
.sidebar { height: calc(100vh - 56px); overflow-y: auto; }
+2
View File
@@ -0,0 +1,2 @@
/* Tenant portal custom styles */
.sidebar { height: calc(100vh - 56px); overflow-y: auto; position: sticky; top: 56px; }
+47
View File
@@ -0,0 +1,47 @@
{% extends "layouts/base.html" %}
{% block title %}Admin Login{% endblock %}
{% block content %}
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
<div class="card shadow" style="width: 400px;">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-shield-lock-fill fs-1 text-dark"></i>
<h4 class="mt-2 fw-bold">Admin Portal</h4>
<p class="text-muted small">Restricted access</p>
</div>
{% if error %}
<div class="alert alert-danger py-2">{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('admin_auth.login') }}" novalidate>
{{ csrf_token() if csrf_token is defined }}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email"
autocomplete="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
autocomplete="current-password" required>
</div>
<div class="d-grid mt-4">
<button type="submit" class="btn btn-dark">Sign In</button>
</div>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('admin_auth.password_reset_request') }}" class="text-muted small">
Forgot password?
</a>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block title %}Set New Password{% endblock %}
{% block content %}
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
<div class="card shadow" style="width: 420px;">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Set New Password</h5>
{% if error %}
<div class="alert alert-danger py-2">{{ error }}</div>
{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="password" class="form-label">New password</label>
<input type="password" class="form-control" id="password" name="password"
autocomplete="new-password" required autofocus>
<div class="form-text">Min 10 characters. Must include uppercase, lowercase, and a digit.</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm password</label>
<input type="password" class="form-control" id="confirm_password"
name="confirm_password" autocomplete="new-password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-dark">Update Password</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "layouts/base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
<div class="card shadow" style="width: 400px;">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Reset Admin Password</h5>
<p class="text-muted small">Enter your registered email address and we'll send a reset link.</p>
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-dark">Send Reset Link</button>
</div>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('admin_auth.login') }}" class="text-muted small">Back to login</a>
</div>
</div>
</div>
</div>
{% endblock %}
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Portal{% endblock %} — Salon POS</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
</head>
<body class="bg-light">
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="{{ url_for('tenants.index') }}">
<i class="bi bi-shield-lock-fill me-2"></i>Admin Portal
</a>
<div class="navbar-nav ms-auto">
<span class="navbar-text text-light me-3">{{ current_user.name }}</span>
<a class="nav-link text-warning" href="{{ url_for('admin_auth.logout') }}">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-2 d-none d-md-block bg-white border-end vh-100 pt-3 position-sticky top-0">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}"
href="{{ url_for('tenants.index') }}">
<i class="bi bi-building me-2"></i>Tenants
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}"
href="{{ url_for('system_users.index') }}">
<i class="bi bi-people me-2"></i>System Users
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}"
href="{{ url_for('plans.index') }}">
<i class="bi bi-card-list me-2"></i>Plans
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}"
href="{{ url_for('billing.index') }}">
<i class="bi bi-receipt me-2"></i>Billing
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}"
href="{{ url_for('audit_log.index') }}">
<i class="bi bi-journal-text me-2"></i>Audit Log
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}"
href="{{ url_for('analytics.index') }}">
<i class="bi bi-bar-chart me-2"></i>Analytics
</a>
</li>
</ul>
</nav>
<!-- Main content -->
<main class="col-md-10 ms-sm-auto px-4 py-3">
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-2" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
{% if current_user.is_authenticated %}
</main>
</div>
</div>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{% extends "layouts/base.html" %}
{% block title %}Account Suspended{% endblock %}
{% block content %}
<div class="d-flex justify-content-center align-items-center" style="min-height: 80vh;">
<div class="text-center">
<i class="bi bi-lock-fill display-1 text-danger"></i>
<h3 class="mt-3">Account Suspended</h3>
<p class="text-muted">
Your account has been suspended. Please contact support to resolve any outstanding issues.
</p>
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary mt-2">
Sign Out
</a>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More