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:
parent
afb5e99bb2
commit
9897d03d91
17 changed files with 975 additions and 2 deletions
6
frontend/src/api/demo.ts
Normal file
6
frontend/src/api/demo.ts
Normal 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;
|
||||
}
|
||||
16
frontend/src/components/DemoBanner.tsx
Normal file
16
frontend/src/components/DemoBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
11
frontend/src/hooks/useDemoMode.ts
Normal file
11
frontend/src/hooks/useDemoMode.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue