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 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 22:08:24 +00:00
parent afb5e99bb2
commit 9897d03d91
17 changed files with 975 additions and 2 deletions

6
frontend/src/api/demo.ts Normal file
View file

@ -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;
}

View file

@ -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 (
<div className="w-full bg-amber-500/15 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2 text-amber-400 text-xs font-medium shrink-0">
<FlaskConical className="w-3.5 h-3.5 shrink-0" />
<span>Demo mode all data is synthetic and resets hourly.</span>
<RefreshCw className="w-3 h-3 shrink-0 ml-0.5" />
<span className="text-amber-400/70">Password changes and backups are disabled.</span>
</div>
);
}

View file

@ -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) {
}`}
>
<TopBar />
<DemoBanner />
{/* Extra bottom padding on mobile so content clears the nav bar */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
<ErrorBoundary>{children}</ErrorBoundary>

View file

@ -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;
}

View file

@ -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() {
<span className="text-2xl font-bold">MyMidas</span>
</div>
{isDemo && (
<div className="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 flex items-start gap-3">
<FlaskConical className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-semibold text-amber-400 mb-1">Demo instance</p>
<p className="text-xs text-amber-400/80 font-mono">Email: <span className="text-amber-300">demo@mymidas.app</span></p>
<p className="text-xs text-amber-400/80 font-mono">Password: <span className="text-amber-300">demo123</span></p>
<p className="text-xs text-amber-400/60 mt-1">Data resets hourly. Password changes disabled.</p>
</div>
</div>
)}
<div className="bg-card border border-border rounded-xl p-8 shadow-xl">
{!challengeToken ? (
<>

View file

@ -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 (
<div className={cardCls}>
@ -196,6 +198,12 @@ function PasswordCard() {
<SectionTitle>Change Password</SectionTitle>
</div>
{isDemo && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-400">
Password changes are disabled in demo mode.
</div>
)}
{success && <SuccessBanner message="Password changed successfully" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Password change failed"} />}
@ -498,6 +506,21 @@ function SessionsSection() {
// ─── Backups ──────────────────────────────────────────────────────────────────
function BackupsSection() {
const isDemo = useDemoMode();
if (isDemo) {
return (
<div className={cardCls}>
<div className="flex items-center gap-2 mb-1">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Backups</SectionTitle>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
Backups are disabled in this demo instance. In a real installation, encrypted nightly backups run automatically and can be downloaded or restored here.
</div>
</div>
);
}
const qc = useQueryClient();
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreSuccess, setRestoreSuccess] = useState("");