05/06/2026 Initial commit
This commit is contained in:
@@ -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 3–6:
|
||||
# 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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/appointments/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/booking/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
booking_bp = Blueprint("booking", __name__, url_prefix="")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/customers/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/inventory/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/locations/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/marketing/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/pos/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reconciliation/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reports/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/reviews/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/services/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
services_bp = Blueprint("services", __name__, url_prefix="/services")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/settings/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/staff/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/tenant/waitlist/routes.py
|
||||
Phase 3+ implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
|
||||
Reference in New Issue
Block a user