From 9897d03d91c2fb083accca53399879ccb23a1760 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 22:08:24 +0000 Subject: [PATCH] Add public demo mode with auto-seeding, hourly reset, and Portainer deploy guide - DEMO_MODE=true env flag: disables password changes and backup endpoints (403), exposes GET /demo/status for frontend detection - Auto-seed on first startup: creates demo user (demo@mymidas.app / demo123) with 6 months of transactions, investments, budgets, subscriptions, and tax payslips; takes a pg_dump snapshot immediately after for hourly restore - Hourly reset: resetter Alpine container with cron restores DB from snapshot and purges uploaded attachments every hour on the hour - Frontend: amber demo banner on all pages, login page shows credentials, password change disabled with notice, backups section replaced with notice - demo/ directory: self-contained docker-compose.yml (ports 4001/8091), .env.example, reset.sh, and step-by-step Portainer DEPLOY.md Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/admin.py | 11 + backend/app/api/v1/users.py | 3 + backend/app/config.py | 5 + backend/app/demo/__init__.py | 0 backend/app/demo/seed.py | 492 +++++++++++++++++++ backend/app/demo/snapshot.py | 51 ++ backend/app/main.py | 16 + demo/.env.example | 18 + demo/DEPLOY.md | 152 ++++++ demo/docker-compose.yml | 128 +++++ demo/reset.sh | 25 + frontend/src/api/demo.ts | 6 + frontend/src/components/DemoBanner.tsx | 16 + frontend/src/components/layout/AppShell.tsx | 2 + frontend/src/hooks/useDemoMode.ts | 11 + frontend/src/pages/auth/Login.tsx | 16 +- frontend/src/pages/settings/SettingsPage.tsx | 25 +- 17 files changed, 975 insertions(+), 2 deletions(-) create mode 100644 backend/app/demo/__init__.py create mode 100644 backend/app/demo/seed.py create mode 100644 backend/app/demo/snapshot.py create mode 100644 demo/.env.example create mode 100644 demo/DEPLOY.md create mode 100644 demo/docker-compose.yml create mode 100644 demo/reset.sh create mode 100644 frontend/src/api/demo.ts create mode 100644 frontend/src/components/DemoBanner.tsx create mode 100644 frontend/src/hooks/useDemoMode.ts diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 9475c90..6a7e0a3 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -8,9 +8,12 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import FileResponse from pydantic import BaseModel +from app.config import get_settings from app.dependencies import get_current_user from app.db.models.user import User +_DEMO_DISABLED = "Backups are disabled in demo mode" + router = APIRouter(prefix="/admin", tags=["admin"]) BACKUP_DIR = Path(os.environ.get("BACKUP_DIR", "/app/backups")) @@ -44,11 +47,15 @@ def _list_backup_files() -> list[BackupFile]: @router.get("/backups", response_model=list[BackupFile]) async def list_backups(current_user: User = Depends(get_current_user)): + if get_settings().is_demo: + raise HTTPException(status_code=403, detail=_DEMO_DISABLED) return _list_backup_files() @router.post("/backup", response_model=BackupResult) async def trigger_backup(current_user: User = Depends(get_current_user)): + if get_settings().is_demo: + raise HTTPException(status_code=403, detail=_DEMO_DISABLED) try: proc = await asyncio.create_subprocess_exec( "bash", "/app/scripts/backup.sh", @@ -71,6 +78,8 @@ async def download_backup( filename: str, current_user: User = Depends(get_current_user), ): + if get_settings().is_demo: + raise HTTPException(status_code=403, detail=_DEMO_DISABLED) if not BACKUP_PATTERN.match(filename): raise HTTPException(status_code=400, detail="Invalid filename") path = BACKUP_DIR / filename @@ -88,6 +97,8 @@ async def restore_backup( filename: str, current_user: User = Depends(get_current_user), ): + if get_settings().is_demo: + raise HTTPException(status_code=403, detail=_DEMO_DISABLED) if not BACKUP_PATTERN.match(filename): raise HTTPException(status_code=400, detail="Invalid filename") path = BACKUP_DIR / filename diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index ae72c91..a119fad 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings from app.core.audit import write_audit from app.core.security import hash_password, verify_password from app.dependencies import get_current_user, get_db @@ -41,6 +42,8 @@ async def change_password( db: AsyncSession = Depends(get_db), user=Depends(get_current_user), ): + if get_settings().is_demo: + raise HTTPException(status_code=403, detail="Password changes are disabled in demo mode") if not verify_password(body.current_password, user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect") user.password_hash = hash_password(body.new_password) diff --git a/backend/app/config.py b/backend/app/config.py index 03c0d03..8f8272b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,6 +14,7 @@ class Settings(BaseSettings): environment: str = "production" allow_registration: bool = False base_currency: str = "GBP" + demo_mode: bool = False # JWT — keys read from /run/secrets/ at runtime jwt_private_key_file: str = "/run/secrets/jwt_private.pem" @@ -48,6 +49,10 @@ class Settings(BaseSettings): def is_development(self) -> bool: return self.environment == "development" + @property + def is_demo(self) -> bool: + return self.demo_mode + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/demo/__init__.py b/backend/app/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/demo/seed.py b/backend/app/demo/seed.py new file mode 100644 index 0000000..be8f7ea --- /dev/null +++ b/backend/app/demo/seed.py @@ -0,0 +1,492 @@ +"""Demo seed — runs automatically on first startup when DEMO_MODE=true.""" +from __future__ import annotations + +import hashlib +import uuid +from datetime import date, datetime, timezone +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import encrypt_field, hash_password +from app.db.models import ( + Account, Asset, Budget, Category, InvestmentHolding, + InvestmentTransaction, ManualCGTDisposal, Payslip, + TaxProfile, Transaction, User, +) + +DEMO_EMAIL = "demo@mymidas.app" +DEMO_PASSWORD = "demo123" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _h(*parts) -> str: + return hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest() + + +async def is_seeded(db: AsyncSession) -> bool: + return bool(await db.scalar(select(User).where(User.email == DEMO_EMAIL))) + + +async def seed_demo(db: AsyncSession) -> None: + if await is_seeded(db): + return + + now = _now() + + # ── User ────────────────────────────────────────────────────────────── + user = User( + id=uuid.uuid4(), + email=DEMO_EMAIL, + password_hash=hash_password(DEMO_PASSWORD), + display_name="Alex Demo", + base_currency="GBP", + theme="obsidian", + locale="en-GB", + created_at=now, + updated_at=now, + ) + db.add(user) + await db.flush() + uid = user.id + + # ── System categories ────────────────────────────────────────────────── + res = await db.execute(select(Category).where(Category.is_system == True)) + cats = {c.name: c for c in res.scalars().all()} + + def cid(name: str): + return cats[name].id if name in cats else None + + # ── Accounts ────────────────────────────────────────────────────────── + def mk_acc(name, institution, kind, balance, color, currency="GBP", credit_limit=None): + return Account( + id=uuid.uuid4(), + user_id=uid, + name_enc=encrypt_field(name), + institution_enc=encrypt_field(institution) if institution else None, + type=kind, + currency=currency, + current_balance=Decimal(str(balance)), + credit_limit=Decimal(str(credit_limit)) if credit_limit else None, + is_active=True, + include_in_net_worth=True, + color=color, + meta={}, + created_at=now, + updated_at=now, + ) + + monzo = mk_acc("Monzo Current Account", "Monzo", "checking", "2847.32", "#ff6b35") + marcus = mk_acc("Marcus Savings", "Goldman Sachs", "savings", "6234.50", "#22c55e") + amex = mk_acc("Amex Gold", "American Express", "credit_card", "-342.18", "#f59e0b", credit_limit=5000) + freetrade = mk_acc("Freetrade Stocks & Shares ISA", "Freetrade", "investment", "0", "#6366f1") + + for acc in [monzo, marcus, amex, freetrade]: + db.add(acc) + await db.flush() + + # ── Transaction helpers ──────────────────────────────────────────────── + def txn(account, txn_type, amount, desc, merchant, cat_name, txn_date, + is_recurring=False, recurring_rule=None): + return Transaction( + id=uuid.uuid4(), + user_id=uid, + account_id=account.id, + type=txn_type, + status="cleared", + amount=Decimal(str(amount)), + amount_base=Decimal(str(amount)), + currency="GBP", + base_currency="GBP", + date=txn_date, + description_enc=encrypt_field(desc), + merchant_enc=encrypt_field(merchant) if merchant else None, + category_id=cid(cat_name), + tags=[], + is_recurring=is_recurring, + recurring_rule=recurring_rule, + attachment_refs=[], + import_hash=_h(uid, account.id, txn_date, amount, desc), + meta={}, + created_at=now, + updated_at=now, + ) + + def rr(freq, amount, next_exp, last_paid): + return { + "frequency": freq, + "typical_amount": float(amount), + "next_expected": next_exp, + "last_paid": last_paid, + "confidence": 0.97, + "manually_set": False, + "detected_at": now.isoformat(), + } + + def next_month(y, m): + return (y + 1, 1) if m == 12 else (y, m + 1) + + def d(y, m, day): + return date(y, m, min(day, [31,28,31,30,31,30,31,31,30,31,30,31][m-1])) + + txns = [] + + # ── Monthly recurring transactions (Oct 2025 – Mar 2026) ────────────── + months = [(2025, 10), (2025, 11), (2025, 12), (2026, 1), (2026, 2), (2026, 3)] + + for year, month in months: + ny, nm = next_month(year, month) + n1 = d(ny, nm, 1).isoformat() + this1 = d(year, month, 1).isoformat() + + # Salary + txns.append(txn(monzo, "income", 3489.00, + "Demo Corp Ltd - Salary", "Demo Corp Ltd", "Salary", + d(year, month, 1), True, rr("monthly", 3489.00, n1, this1))) + + # Rent + txns.append(txn(monzo, "expense", -1250.00, + "DIRECT DEBIT ANYLETS PROPERTY MGMT", "Anylets", "Rent / Mortgage", + d(year, month, 1), True, rr("monthly", -1250.00, n1, this1))) + + # Council tax + txns.append(txn(monzo, "expense", -145.00, + "DIRECT DEBIT LONDON BOROUGH COUNCIL TAX", "London Borough", "Council Tax", + d(year, month, 1), True, rr("monthly", -145.00, n1, this1))) + + # Internet + txns.append(txn(monzo, "expense", -35.00, + "DIRECT DEBIT BT BROADBAND", "BT", "Internet", + d(year, month, 2), True, rr("monthly", -35.00, d(ny, nm, 2).isoformat(), d(year, month, 2).isoformat()))) + + # Phone + txns.append(txn(monzo, "expense", -25.00, + "DIRECT DEBIT EE MOBILE", "EE", "Phone", + d(year, month, 3), True, rr("monthly", -25.00, d(ny, nm, 3).isoformat(), d(year, month, 3).isoformat()))) + + # Energy (variable) + energy = [-102, -98, -115, -108, -103, -95][months.index((year, month))] + txns.append(txn(monzo, "expense", energy, + "DIRECT DEBIT OVO ENERGY", "OVO Energy", "Electricity", + d(year, month, 5), True, rr("monthly", energy, d(ny, nm, 5).isoformat(), d(year, month, 5).isoformat()))) + + # Subscriptions + for sub_desc, sub_merch, sub_amt in [ + ("DIRECT DEBIT NETFLIX", "Netflix", -17.99), + ("DIRECT DEBIT SPOTIFY", "Spotify", -11.99), + ]: + txns.append(txn(monzo, "expense", sub_amt, sub_desc, sub_merch, "Subscriptions", + d(year, month, 5), True, rr("monthly", sub_amt, d(ny, nm, 5).isoformat(), d(year, month, 5).isoformat()))) + + for sub_desc, sub_merch, sub_amt in [ + ("DIRECT DEBIT AMAZON PRIME", "Amazon Prime", -8.99), + ("DIRECT DEBIT APPLE ICLOUD", "Apple iCloud", -2.99), + ("DIRECT DEBIT GITHUB", "GitHub", -3.99), + ]: + txns.append(txn(monzo, "expense", sub_amt, sub_desc, sub_merch, "Subscriptions", + d(year, month, 6), True, rr("monthly", sub_amt, d(ny, nm, 6).isoformat(), d(year, month, 6).isoformat()))) + + # Gym + txns.append(txn(monzo, "expense", -35.00, + "DIRECT DEBIT PUREGYM", "PureGym", "Gym", + d(year, month, 7), True, rr("monthly", -35.00, d(ny, nm, 7).isoformat(), d(year, month, 7).isoformat()))) + + # TfL top-ups (2/month) + tfl = [(-40, 17, -30, 29), (-35, 15, -40, 28), (-40, 16, -35, 30), + (-40, 17, -30, 28), (-40, 14, -35, 27), (-40, 17, -30, 29)][months.index((year, month))] + txns.append(txn(monzo, "expense", tfl[0], "TfL Travel Top-Up", "Transport for London", + "Public Transport", d(year, month, tfl[1]))) + txns.append(txn(monzo, "expense", tfl[2], "TfL Travel Top-Up", "Transport for London", + "Public Transport", d(year, month, tfl[3]))) + + # Transfer to savings + out_id = uuid.uuid4() + inn_id = uuid.uuid4() + sav_rr = rr("monthly", -300.00, d(ny, nm, 15).isoformat(), d(year, month, 15).isoformat()) + txns.append(Transaction( + id=out_id, user_id=uid, account_id=monzo.id, + transfer_account_id=marcus.id, type="transfer", status="cleared", + amount=Decimal("-300.00"), amount_base=Decimal("-300.00"), + currency="GBP", base_currency="GBP", date=d(year, month, 15), + description_enc=encrypt_field("Transfer to Marcus Savings"), + category_id=cid("Transfer"), tags=[], is_recurring=True, recurring_rule=sav_rr, + attachment_refs=[], import_hash=_h(uid, monzo.id, year, month, "transfer_out"), + meta={}, created_at=now, updated_at=now, + )) + txns.append(Transaction( + id=inn_id, user_id=uid, account_id=marcus.id, + transfer_account_id=monzo.id, type="transfer", status="cleared", + amount=Decimal("300.00"), amount_base=Decimal("300.00"), + currency="GBP", base_currency="GBP", date=d(year, month, 15), + description_enc=encrypt_field("Transfer from Monzo"), + category_id=cid("Transfer"), tags=[], is_recurring=True, recurring_rule=sav_rr, + attachment_refs=[], import_hash=_h(uid, marcus.id, year, month, "transfer_in"), + meta={}, created_at=now, updated_at=now, + )) + + # ── Groceries ───────────────────────────────────────────────────────── + groceries = [ + (2025,10, 9, -71.43, "Tesco"), (2025,10, 16, -63.21, "Sainsbury's"), + (2025,10, 23, -58.76, "Tesco"), (2025,10, 28, -47.30, "Lidl"), + (2025,10, 30, -65.80, "Tesco"), (2025,11, 8, -68.92, "Tesco"), + (2025,11, 15, -72.10, "Sainsbury's"), (2025,11, 21, -55.40, "Lidl"), + (2025,11, 27, -61.33, "Tesco"), (2025,12, 6, -82.14, "Waitrose"), + (2025,12, 13, -65.20, "Sainsbury's"), (2025,12, 20, -71.50, "Tesco"), + (2025,12, 27, -90.30, "Waitrose"), (2026, 1, 10, -66.45, "Tesco"), + (2026, 1, 17, -59.80, "Lidl"), (2026, 1, 24, -74.20, "Sainsbury's"), + (2026, 1, 31, -68.90, "Tesco"), (2026, 2, 7, -73.15, "Tesco"), + (2026, 2, 14, -61.40, "Sainsbury's"), (2026, 2, 21, -55.90, "Lidl"), + (2026, 2, 28, -80.45, "Waitrose"), (2026, 3, 7, -69.30, "Tesco"), + (2026, 3, 14, -58.75, "Lidl"), (2026, 3, 21, -76.20, "Sainsbury's"), + (2026, 3, 28, -65.80, "Tesco"), + ] + for y, m, day, amt, merch in groceries: + txns.append(txn(monzo, "expense", amt, f"{merch} Groceries", merch, "Groceries", date(y, m, day))) + + # ── Eating out ──────────────────────────────────────────────────────── + eating_out = [ + (2025,10, 14, -42.00, "Wagamama"), (2025,10, 19, -28.50, "Deliveroo"), + (2025,10, 26, -55.00, "Dishoom"), (2025,11, 8, -35.00, "Nando's"), + (2025,11, 22, -48.50, "Carluccio's"), (2025,11, 28, -22.00, "Deliveroo"), + (2025,12, 12, -65.00, "Gaucho"), (2025,12, 19, -38.00, "Pizza Express"), + (2025,12, 23, -52.00, "Dishoom"), (2026, 1, 10, -29.00, "Deliveroo"), + (2026, 1, 17, -45.00, "Wagamama"), (2026, 1, 25, -38.50, "Nando's"), + (2026, 2, 6, -42.00, "Dishoom"), (2026, 2, 14, -78.00, "Restaurant"), + (2026, 2, 22, -31.50, "Deliveroo"), (2026, 3, 8, -38.00, "Wagamama"), + (2026, 3, 15, -25.00, "Deliveroo"), (2026, 3, 22, -47.00, "Nando's"), + ] + for y, m, day, amt, merch in eating_out: + txns.append(txn(monzo, "expense", amt, merch, merch, "Eating Out", date(y, m, day))) + + # ── Coffee ──────────────────────────────────────────────────────────── + coffee = [ + (2025,10, 12, -4.80, "Costa Coffee"), (2025,10, 17, -8.50, "Pret a Manger"), + (2025,10, 24, -5.20, "Starbucks"), (2025,11, 5, -4.50, "Costa Coffee"), + (2025,11, 13, -5.20, "Starbucks"), (2025,11, 20, -4.80, "Pret a Manger"), + (2025,11, 27, -6.50, "Blank Street"), (2025,12, 4, -5.20, "Starbucks"), + (2025,12, 11, -4.80, "Costa Coffee"), (2025,12, 18, -5.00, "Pret a Manger"), + (2026, 1, 9, -4.50, "Costa Coffee"), (2026, 1, 16, -5.20, "Starbucks"), + (2026, 1, 23, -4.80, "Pret a Manger"), (2026, 2, 5, -5.20, "Starbucks"), + (2026, 2, 12, -4.80, "Costa Coffee"), (2026, 2, 20, -5.50, "Blank Street"), + (2026, 2, 27, -4.80, "Pret a Manger"), (2026, 3, 5, -4.80, "Costa Coffee"), + (2026, 3, 12, -5.20, "Starbucks"), (2026, 3, 19, -4.50, "Pret a Manger"), + ] + for y, m, day, amt, merch in coffee: + txns.append(txn(monzo, "expense", amt, merch, merch, "Coffee", date(y, m, day))) + + # ── Shopping / other ────────────────────────────────────────────────── + shopping = [ + (2025,10, 21, -34.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,10, 31, -12.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,10, 25, -89.00, monzo, "ASOS", "ASOS", "Clothing"), + (2025,11, 14, -89.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,11, 18, -85.00, monzo, "Dental Practice", "Dental Practice", "Healthcare"), + (2025,11, 28, -22.50, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,12, 3, -156.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,12, 5, -650.00, monzo, "EasyJet", "EasyJet", "Holidays"), + (2025,12, 5, -380.00, monzo, "Booking.com", "Booking.com", "Holidays"), + (2025,12, 15, -210.00, monzo, "Amazon", "AMAZON.CO.UK", "Gifts"), + (2025,12, 16, -85.00, monzo, "John Lewis", "John Lewis", "Gifts"), + (2025,12, 18, -45.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2025,12, 20, -120.00, monzo, "Airbnb", "Airbnb", "Holidays"), + (2026, 1, 12, -28.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2026, 1, 26, -67.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2026, 2, 10, -44.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2026, 2, 18, -75.00, monzo, "Uniqlo", "Uniqlo", "Clothing"), + (2026, 2, 25, -19.99, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2026, 3, 8, -38.50, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + (2026, 3, 22, -55.00, monzo, "Amazon", "AMAZON.CO.UK", "Other Expense"), + # Car insurance (October, annual) + (2025,10, 15, -485.00, monzo, "Aviva", "AVIVA CAR INSURANCE ANNUAL", "Car Insurance"), + # Amex purchases + (2025,10, 20, -320.00, amex, "M&S", "MARKS AND SPENCER", "Groceries"), + (2025,11, 8, -145.00, amex, "Restaurant", "RESTAURANT GORDON RAMSAY", "Eating Out"), + (2025,12, 22, -89.00, amex, "Apple", "APPLE STORE", "Other Expense"), + (2026, 1, 25, -215.00, amex, "Sofitel", "SOFITEL HOTEL", "Holidays"), + (2026, 2, 14, -178.50, amex, "Selfridges", "SELFRIDGES", "Clothing"), + (2026, 3, 8, -95.00, amex, "Harvey Nichols", "HARVEY NICHOLS", "Personal Care"), + # Savings interest (quarterly) + (2025,10, 28, 12.50, marcus, "Goldman Sachs", "Marcus Savings Interest", "Investment Income"), + (2026, 1, 28, 13.20, marcus, "Goldman Sachs", "Marcus Savings Interest", "Investment Income"), + ] + for row in shopping: + y, m, day, amt, acc, merch, desc, cat = row + txn_type = "income" if amt > 0 else "expense" + txns.append(txn(acc, txn_type, amt, desc, merch, cat, date(y, m, day))) + + for t in txns: + db.add(t) + await db.flush() + + # ── Budgets ─────────────────────────────────────────────────────────── + budget_defs = [ + ("Groceries", 300.00, "Groceries"), + ("Eating Out", 200.00, "Eating Out"), + ("Transport", 100.00, "Public Transport"), + ("Entertainment", 80.00, "Entertainment"), + ("Utilities", 180.00, "Electricity"), + ("Subscriptions", 60.00, "Subscriptions"), + ("Shopping", 200.00, "Clothing"), + ] + for bname, amount, cat_name in budget_defs: + cat_id = cid(cat_name) + if not cat_id: + continue + db.add(Budget( + id=uuid.uuid4(), + user_id=uid, + category_id=cat_id, + name=bname, + amount=Decimal(str(amount)), + currency="GBP", + period="monthly", + start_date=date(2025, 10, 1), + rollover=False, + alert_threshold=Decimal("80"), + is_active=True, + created_at=now, + updated_at=now, + )) + await db.flush() + + # ── Assets ──────────────────────────────────────────────────────────── + vwrp = Asset( + id=uuid.uuid4(), symbol="VWRP.L", + name="Vanguard FTSE All-World UCITS ETF", type="etf", + currency="GBP", exchange="LSE", data_source="yahoo_finance", + last_price=Decimal("107.50"), last_price_at=now, + price_change_24h=Decimal("0.85"), is_active=True, + created_at=now, updated_at=now, + ) + aapl = Asset( + id=uuid.uuid4(), symbol="AAPL", name="Apple Inc.", type="stock", + currency="USD", exchange="NASDAQ", data_source="yahoo_finance", + last_price=Decimal("212.50"), last_price_at=now, + price_change_24h=Decimal("-0.45"), is_active=True, + created_at=now, updated_at=now, + ) + btc = Asset( + id=uuid.uuid4(), symbol="BTC-USD", name="Bitcoin", type="crypto", + currency="USD", exchange=None, data_source="coingecko", + last_price=Decimal("84500.00"), last_price_at=now, + price_change_24h=Decimal("2.30"), is_active=True, + created_at=now, updated_at=now, + ) + for a in [vwrp, aapl, btc]: + db.add(a) + await db.flush() + + # ── Holdings ────────────────────────────────────────────────────────── + # Quantities match investment transactions below — do not double-count. + vwrp_h = InvestmentHolding( + id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=vwrp.id, + quantity=Decimal("50"), avg_cost_basis=Decimal("101.21"), + currency="GBP", created_at=now, updated_at=now, + ) + aapl_h = InvestmentHolding( + id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=aapl.id, + quantity=Decimal("10"), avg_cost_basis=Decimal("228.50"), + currency="USD", created_at=now, updated_at=now, + ) + btc_h = InvestmentHolding( + id=uuid.uuid4(), user_id=uid, account_id=freetrade.id, asset_id=btc.id, + quantity=Decimal("0.05"), avg_cost_basis=Decimal("69500.00"), + currency="USD", created_at=now, updated_at=now, + ) + for h in [vwrp_h, aapl_h, btc_h]: + db.add(h) + await db.flush() + + # ── Investment transactions (history only — holding quantities already set) ── + inv_txns = [ + # VWRP: 3 buys totalling 50 shares + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, + type="buy", quantity=Decimal("15"), price=Decimal("98.50"), + fees=Decimal("0"), total_amount=Decimal("1477.50"), + currency="GBP", date=date(2025, 10, 5), created_at=now, + ), + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, + type="buy", quantity=Decimal("20"), price=Decimal("102.30"), + fees=Decimal("0"), total_amount=Decimal("2046.00"), + currency="GBP", date=date(2025, 12, 10), created_at=now, + ), + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, + type="buy", quantity=Decimal("15"), price=Decimal("105.80"), + fees=Decimal("0"), total_amount=Decimal("1587.00"), + currency="GBP", date=date(2026, 2, 14), created_at=now, + ), + # VWRP dividend + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=vwrp_h.id, + type="dividend", quantity=Decimal("0"), price=Decimal("0"), + fees=Decimal("0"), total_amount=Decimal("62.50"), + currency="GBP", date=date(2025, 12, 20), created_at=now, + ), + # AAPL buy + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=aapl_h.id, + type="buy", quantity=Decimal("10"), price=Decimal("228.50"), + fees=Decimal("0"), total_amount=Decimal("2285.00"), + currency="USD", date=date(2025, 10, 15), created_at=now, + ), + # AAPL dividend + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=aapl_h.id, + type="dividend", quantity=Decimal("0"), price=Decimal("0"), + fees=Decimal("0"), total_amount=Decimal("23.00"), + currency="USD", date=date(2026, 2, 15), created_at=now, + ), + # BTC buy + InvestmentTransaction( + id=uuid.uuid4(), user_id=uid, holding_id=btc_h.id, + type="buy", quantity=Decimal("0.05"), price=Decimal("69500.00"), + fees=Decimal("0"), total_amount=Decimal("3475.00"), + currency="USD", date=date(2025, 11, 1), created_at=now, + ), + ] + for it in inv_txns: + db.add(it) + await db.flush() + + # ── Tax profile & payslips (2025/26 = tax_year 2026) ───────────────── + tax_profile = TaxProfile( + id=uuid.uuid4(), user_id=uid, tax_year=2026, + employer_name_enc=encrypt_field("Demo Corp Ltd"), + tax_code="1257L", is_cumulative=True, + created_at=now, updated_at=now, + ) + db.add(tax_profile) + await db.flush() + + # 6 payslips: April – September 2025 + for month in range(4, 10): + db.add(Payslip( + id=uuid.uuid4(), user_id=uid, + tax_profile_id=tax_profile.id, + period_month=month, period_year=2025, + gross_pay=Decimal("4500.00"), + income_tax_withheld=Decimal("753.00"), + ni_withheld=Decimal("258.00"), + net_pay=Decimal("3489.00"), + is_p60=False, + created_at=now, + )) + + # Manual CGT disposal — employee share scheme, gain exceeds annual exempt + db.add(ManualCGTDisposal( + id=uuid.uuid4(), user_id=uid, tax_year=2026, + disposal_date=date(2025, 9, 15), + asset_description_enc=encrypt_field("Tech Corp Ltd — Employee Share Scheme"), + proceeds=Decimal("8500.00"), + cost_basis=Decimal("4200.00"), + created_at=now, + )) + + await db.flush() diff --git a/backend/app/demo/snapshot.py b/backend/app/demo/snapshot.py new file mode 100644 index 0000000..6aef3cc --- /dev/null +++ b/backend/app/demo/snapshot.py @@ -0,0 +1,51 @@ +"""Creates and restores the demo database snapshot used for hourly resets.""" +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +SNAPSHOT_PATH = Path(os.environ.get("DEMO_SNAPSHOT_PATH", "/app/demo_snapshot.sql.gz")) + + +async def create_snapshot() -> None: + """pg_dump the current DB to SNAPSHOT_PATH (gzip compressed).""" + db_url = os.environ.get("DATABASE_URL", "") + pg_url = db_url.replace("postgresql+asyncpg", "postgresql") + + proc = await asyncio.create_subprocess_shell( + f'pg_dump --no-owner --no-acl "{pg_url}" | gzip > "{SNAPSHOT_PATH}"', + stderr=asyncio.subprocess.PIPE, + ) + _, err = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"Snapshot failed: {err.decode()}") + + +async def restore_snapshot() -> None: + """Restore DB from SNAPSHOT_PATH, dropping and recreating all user data.""" + if not SNAPSHOT_PATH.exists(): + raise FileNotFoundError(f"Snapshot not found: {SNAPSHOT_PATH}") + + db_url = os.environ.get("DATABASE_URL", "") + pg_url = db_url.replace("postgresql+asyncpg", "postgresql") + + # Truncate all user-data tables in dependency order, then restore + truncate_sql = """ + TRUNCATE TABLE + manual_cgt_disposals, payslips, tax_profiles, tax_rate_configs, + investment_transactions, investment_holdings, assets, + audit_logs, net_worth_snapshots, + transactions, budgets, accounts, categories, + sessions, users + RESTART IDENTITY CASCADE; + """ + + proc = await asyncio.create_subprocess_shell( + f'gunzip -c "{SNAPSHOT_PATH}" | psql --single-transaction -v ON_ERROR_STOP=1 "{pg_url}"', + stderr=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + _, err = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(f"Restore failed: {err.decode()}") diff --git a/backend/app/main.py b/backend/app/main.py index 4eb00fc..3f0dc7c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,18 @@ async def lifespan(app: FastAPI): await seed_system_categories(db) await db.commit() + # Demo mode: seed demo data on first startup, then snapshot + if settings.is_demo: + from app.demo.seed import is_seeded, seed_demo + from app.demo.snapshot import create_snapshot + async with session_factory() as db: + if not await is_seeded(db): + await seed_demo(db) + await db.commit() + logger.info("demo_seed_complete") + await create_snapshot() + logger.info("demo_snapshot_created") + # Background scheduler from app.workers.scheduler import start_scheduler, stop_scheduler await start_scheduler() @@ -78,6 +90,10 @@ def create_app() -> FastAPI: async def health(): return {"status": "ok"} + @app.get("/demo/status") + async def demo_status(): + return {"demo_mode": settings.is_demo} + # API routers from app.api.router import router app.include_router(router, prefix="/api/v1") diff --git a/demo/.env.example b/demo/.env.example new file mode 100644 index 0000000..5bd69f2 --- /dev/null +++ b/demo/.env.example @@ -0,0 +1,18 @@ +# MyMidas Demo — environment variables +# Copy to .env and fill in every value before deploying. + +# ── Encryption ──────────────────────────────────────────────────────────────── +# 32-byte hex key for AES-256-GCM field encryption. +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +ENCRYPTION_KEY= + +# ── Database ────────────────────────────────────────────────────────────────── +# Strong random password for the demo Postgres user. +DB_PASSWORD= + +# ── Redis ───────────────────────────────────────────────────────────────────── +REDIS_PASSWORD= + +# ── Environment ─────────────────────────────────────────────────────────────── +# Keep as "production" (hides /docs, enforces security headers). +ENVIRONMENT=production diff --git a/demo/DEPLOY.md b/demo/DEPLOY.md new file mode 100644 index 0000000..7387545 --- /dev/null +++ b/demo/DEPLOY.md @@ -0,0 +1,152 @@ +# MyMidas Demo — Deployment Guide (Portainer) + +This guide deploys the public demo instance on a separate server using Portainer Stacks. + +**What you get:** a fully seeded MyMidas instance at port 4001, with demo data and an hourly auto-reset. No manual steps after initial deploy. + +--- + +## Prerequisites + +- Docker and Portainer installed on the demo server +- SSH or console access to the demo server (for the initial clone and key generation) +- Your reverse proxy pointing a public domain at port `4001` on this server + +--- + +## Step 1 — Clone the repo + +On the demo server, clone into your preferred location: + +```bash +git clone https://git.rdx4.com/megaproxy/MyMidas.git +cd MyMidas +``` + +--- + +## Step 2 — Generate JWT keys + +The demo shares the `secrets/` directory with the main app structure. If you've already generated keys on this server you can skip this. + +```bash +mkdir -p secrets +openssl genrsa -out secrets/jwt_private.pem 4096 +openssl rsa -in secrets/jwt_private.pem -pubout -out secrets/jwt_public.pem +``` + +--- + +## Step 3 — Create the demo .env file + +```bash +cd demo +cp .env.example .env +``` + +Open `.env` and fill in the three required values: + +| Variable | How to generate | +|---|---| +| `ENCRYPTION_KEY` | `python3 -c "import secrets; print(secrets.token_hex(32))"` | +| `DB_PASSWORD` | Any strong random string | +| `REDIS_PASSWORD` | Any strong random string | + +Leave `ENVIRONMENT=production`. + +--- + +## Step 4 — Deploy the stack in Portainer + +1. Open Portainer → **Stacks** → **Add stack** +2. Name it `mymidas-demo` +3. Select **Repository** as the build method +4. Set the repository URL to your Gitea URL and branch `main` +5. Set **Compose path** to `demo/docker-compose.yml` +6. Under **Environment variables**, add the four variables from your `.env`: + - `ENCRYPTION_KEY` + - `DB_PASSWORD` + - `REDIS_PASSWORD` + - `ENVIRONMENT` = `production` +7. Click **Deploy the stack** + +> **Alternative (upload method):** If you prefer to upload the compose file directly, paste the contents of `demo/docker-compose.yml` into Portainer's web editor and add the environment variables manually. + +--- + +## Step 5 — Wait for first-time seeding + +On first startup, the backend will: +1. Run database migrations +2. Detect that `DEMO_MODE=true` and no users exist +3. Seed the full demo dataset (~180 transactions, investments, budgets, tax data) +4. Save a compressed snapshot (`demo_snapshot.sql.gz`) for hourly resets + +This takes about 30–60 seconds. Watch progress in Portainer → **Containers** → `mymidas-demo-backend-1` → **Logs**. + +You'll see these log lines when ready: +``` +demo_seed_complete +demo_snapshot_created +Uvicorn running on http://0.0.0.0:8000 +``` + +--- + +## Step 6 — Configure your reverse proxy + +Point your public domain at `http://:4001`. + +The frontend serves the React app and proxies all `/api/` calls to the backend internally — so you only need to expose port `4001`. + +**nginx proxy manager example:** +- Scheme: `http` +- Forward hostname/IP: `` +- Forward port: `4001` +- Enable websockets: off + +--- + +## Step 7 — Verify + +Open your domain in a browser. You should see the MyMidas login page with a yellow demo credentials banner: + +``` +Email: demo@mymidas.app +Password: demo123 +``` + +Log in and confirm the data is populated (accounts, transactions, investments, tax page). + +--- + +## Hourly reset + +The `resetter` container runs a cron job at the top of every hour that: +1. Restores the database from the snapshot taken on first boot +2. Deletes any files uploaded by demo users + +No action needed — it runs automatically. You can check reset logs in Portainer → **Containers** → `mymidas-demo-resetter-1` → **Logs**. + +--- + +## Updating the demo + +When you push code changes to main: + +1. In Portainer → **Stacks** → `mymidas-demo` → **Editor** → **Update the stack** +2. This rebuilds the images and restarts all containers +3. On restart, migrations run automatically; the seed check runs and skips if already seeded (snapshot is preserved on the `demo_snapshot` volume) + +> If you want to **force a full re-seed** (e.g. after adding more demo data): in Portainer, delete the `demo_snapshot` volume, then redeploy. The backend will re-seed and take a new snapshot on next startup. + +--- + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Blank page / 502 | Backend still starting — wait 60 s and refresh | +| Login fails | Seeding still in progress — check backend logs | +| Data not resetting | Check resetter logs; confirm `demo_snapshot` volume has the `.sql.gz` file | +| "Snapshot not found" in resetter log | Backend may not have finished first-time seed — redeploy backend only | diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 0000000..34bce77 --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,128 @@ +services: + backend: + build: + context: ../backend + dockerfile: Dockerfile + target: production + restart: unless-stopped + ports: + - "8091:8000" + environment: + DATABASE_URL: "postgresql+asyncpg://demo_app:${DB_PASSWORD}@postgres:5432/demodb" + REDIS_URL: "redis://:${REDIS_PASSWORD}@redis:6379/0" + ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + BACKUP_PASSPHRASE: "not-used-in-demo" + ENVIRONMENT: "${ENVIRONMENT:-production}" + ALLOW_REGISTRATION: "false" + BASE_CURRENCY: "GBP" + DEMO_MODE: "true" + DEMO_SNAPSHOT_PATH: "/app/demo_snapshot.sql.gz" + volumes: + - ../secrets:/run/secrets:ro + - demo_snapshot:/app/demo_snapshot.sql.gz:rw + - demo_uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - frontend_net + - backend_net + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + target: production + restart: unless-stopped + ports: + - "4001:3000" + networks: + - frontend_net + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp + - /var/cache/nginx + - /var/run + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: demodb + POSTGRES_USER: demo_app + POSTGRES_PASSWORD: "${DB_PASSWORD}" + volumes: + - demo_postgres:/var/lib/postgresql/data + - ../postgres/init:/docker-entrypoint-initdb.d:ro + networks: + - backend_net + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U demo_app -d demodb"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --requirepass "${REDIS_PASSWORD}" + networks: + - backend_net + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + resetter: + image: alpine:3.19 + restart: unless-stopped + environment: + DATABASE_URL: "postgresql://demo_app:${DB_PASSWORD}@postgres:5432/demodb" + DEMO_SNAPSHOT_PATH: "/snapshot/demo_snapshot.sql.gz" + BACKEND_URL: "http://backend:8000" + volumes: + - demo_snapshot:/snapshot:ro + - demo_uploads:/uploads:rw + - ./reset.sh:/reset.sh:ro + networks: + - backend_net + depends_on: + backend: + condition: service_healthy + entrypoint: > + sh -c " + apk add --no-cache postgresql-client curl && + echo '0 * * * * sh /reset.sh >> /var/log/reset.log 2>&1' | crontab - && + crond -f -l 6 + " + security_opt: + - no-new-privileges:true + +volumes: + demo_postgres: + demo_snapshot: + demo_uploads: + +networks: + frontend_net: + driver: bridge + backend_net: + driver: bridge + internal: true diff --git a/demo/reset.sh b/demo/reset.sh new file mode 100644 index 0000000..51c5b39 --- /dev/null +++ b/demo/reset.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Hourly demo reset — restore DB from snapshot, purge uploads, bounce backend. +set -e + +SNAPSHOT="${DEMO_SNAPSHOT_PATH:-/snapshot/demo_snapshot.sql.gz}" +UPLOADS_DIR="${UPLOADS_DIR:-/uploads}" +DB_URL="${DATABASE_URL}" +BACKEND="${BACKEND_URL:-http://backend:8000}" + +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Starting demo reset" + +# 1. Restore database from snapshot +if [ ! -f "$SNAPSHOT" ]; then + echo "ERROR: Snapshot not found at $SNAPSHOT — skipping reset" + exit 1 +fi + +gunzip -c "$SNAPSHOT" | psql --single-transaction -v ON_ERROR_STOP=1 "$DB_URL" +echo " DB restored from snapshot" + +# 2. Purge uploaded files (attachments added by demo users) +find "$UPLOADS_DIR" -type f -not -name ".gitkeep" -delete 2>/dev/null || true +echo " Uploads purged" + +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Demo reset complete" diff --git a/frontend/src/api/demo.ts b/frontend/src/api/demo.ts new file mode 100644 index 0000000..d08efd9 --- /dev/null +++ b/frontend/src/api/demo.ts @@ -0,0 +1,6 @@ +import { api } from "./client"; + +export async function getDemoStatus(): Promise<{ demo_mode: boolean }> { + const r = await api.get<{ demo_mode: boolean }>("/demo/status"); + return r.data; +} diff --git a/frontend/src/components/DemoBanner.tsx b/frontend/src/components/DemoBanner.tsx new file mode 100644 index 0000000..ca203cb --- /dev/null +++ b/frontend/src/components/DemoBanner.tsx @@ -0,0 +1,16 @@ +import { FlaskConical, RefreshCw } from "lucide-react"; +import { useDemoMode } from "@/hooks/useDemoMode"; + +export default function DemoBanner() { + const isDemo = useDemoMode(); + if (!isDemo) return null; + + return ( +
+ + Demo mode — all data is synthetic and resets hourly. + + Password changes and backups are disabled. +
+ ); +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 93befa2..e9cb127 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -3,6 +3,7 @@ import Sidebar from "./Sidebar"; import TopBar from "./TopBar"; import MobileNav from "./MobileNav"; import ErrorBoundary from "@/components/ErrorBoundary"; +import DemoBanner from "@/components/DemoBanner"; interface AppShellProps { children: React.ReactNode; @@ -24,6 +25,7 @@ export default function AppShell({ children }: AppShellProps) { }`} > + {/* Extra bottom padding on mobile so content clears the nav bar */}
{children} diff --git a/frontend/src/hooks/useDemoMode.ts b/frontend/src/hooks/useDemoMode.ts new file mode 100644 index 0000000..4df6ef9 --- /dev/null +++ b/frontend/src/hooks/useDemoMode.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { getDemoStatus } from "@/api/demo"; + +export function useDemoMode() { + const { data } = useQuery({ + queryKey: ["demo-status"], + queryFn: getDemoStatus, + staleTime: Infinity, + }); + return data?.demo_mode ?? false; +} diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx index 036b89b..13c5371 100644 --- a/frontend/src/pages/auth/Login.tsx +++ b/frontend/src/pages/auth/Login.tsx @@ -2,11 +2,13 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { login, loginTotp, getMe } from "@/api/auth"; import { useAuthStore } from "@/store/authStore"; -import { Coins, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react"; +import { Coins, Eye, EyeOff, FlaskConical, Loader2, ShieldCheck } from "lucide-react"; +import { useDemoMode } from "@/hooks/useDemoMode"; export default function LoginPage() { const navigate = useNavigate(); const { setToken, setTotpEnabled } = useAuthStore(); + const isDemo = useDemoMode(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -83,6 +85,18 @@ export default function LoginPage() { MyMidas + {isDemo && ( +
+ +
+

Demo instance

+

Email: demo@mymidas.app

+

Password: demo123

+

Data resets hourly. Password changes disabled.

+
+
+ )} +
{!challengeToken ? ( <> diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 9e4dd9d..c77bb34 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -15,6 +15,7 @@ import type { AiSettings } from "@/api/settings"; import type { BackupFile } from "@/api/admin"; import { cn } from "@/utils/cn"; import { format } from "date-fns"; +import { useDemoMode } from "@/hooks/useDemoMode"; import { User, Shield, MonitorSmartphone, Download, HardDrive, Loader2, CheckCircle, Eye, EyeOff, Trash2, @@ -169,6 +170,7 @@ function SecuritySection() { } function PasswordCard() { + const isDemo = useDemoMode(); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [confirm, setConfirm] = useState(""); @@ -187,7 +189,7 @@ function PasswordCard() { const mismatch = next.length > 0 && confirm.length > 0 && next !== confirm; const tooShort = next.length > 0 && next.length < 10; - const canSubmit = current && next && confirm && next === confirm && next.length >= 10; + const canSubmit = !isDemo && current && next && confirm && next === confirm && next.length >= 10; return (
@@ -196,6 +198,12 @@ function PasswordCard() { Change Password
+ {isDemo && ( +
+ Password changes are disabled in demo mode. +
+ )} + {success && } {mutation.isError && } @@ -498,6 +506,21 @@ function SessionsSection() { // ─── Backups ────────────────────────────────────────────────────────────────── function BackupsSection() { + const isDemo = useDemoMode(); + if (isDemo) { + return ( +
+
+ + Backups +
+
+ Backups are disabled in this demo instance. In a real installation, encrypted nightly backups run automatically and can be downloaded or restored here. +
+
+ ); + } + const qc = useQueryClient(); const [restoreTarget, setRestoreTarget] = useState(null); const [restoreSuccess, setRestoreSuccess] = useState("");