Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ jobs:
docker-build:
name: Docker build (production targets)
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'

steps:
- uses: actions/checkout@v4
Expand Down
25 changes: 25 additions & 0 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ async def demo_login(request: Request, db: AsyncSession = Depends(get_db)) -> Au
"automatic_monthly_reports": False,
"is_admin": False,
"is_demo": True,
"demo_character": "bruce",
},
)


@router.post("/demo-login-peter", response_model=AuthResponse)
async def demo_login_peter(request: Request, db: AsyncSession = Depends(get_db)) -> AuthResponse:
"""Issue a backend JWT for the Peter Parker demo account — no Google auth required."""
from app.services.demo_seed import seed_peter_data

user = await seed_peter_data(db)

token = create_access_token(user.id, user.email)
return AuthResponse(
access_token=token,
user={
"id": str(user.id),
"email": user.email,
"name": user.name,
"avatar_url": user.avatar_url,
"status": "approved",
"automatic_monthly_reports": False,
"is_admin": False,
"is_demo": True,
"demo_character": "peter",
},
)

Expand Down
531 changes: 526 additions & 5 deletions backend/app/services/demo_seed.py

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions backend/app/services/llm/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,14 @@ def get_llm_provider(config: LLMConfig | None = None) -> BaseLLMProvider:
return MeteredLLMProvider(OllamaProvider(), "ollama")


_DEMO_USER_EMAIL = "bruce.wayne@wayneenterprises.com"
_DEMO_USER_EMAILS = frozenset({
"bruce.wayne@wayneenterprises.com",
"peter.parker@dailybugle.com",
})


def _is_demo_user(user: User) -> bool:
return user.email == _DEMO_USER_EMAIL
return user.email in _DEMO_USER_EMAILS


def resolve_user_llm_config(
Expand Down Expand Up @@ -154,7 +157,12 @@ def resolve_user_llm_config(
)

# Non-admin (including demo user) with no stored key and no request key → error
demo_suffix = " Bruce Wayne can afford it." if _is_demo_user(user) else ""
if user.email == "bruce.wayne@wayneenterprises.com":
demo_suffix = " Bruce Wayne can afford it."
elif user.email == "peter.parker@dailybugle.com":
demo_suffix = " Peter Parker cannot, but that's the point."
else:
demo_suffix = ""
raise HTTPException(
status_code=400,
detail=(
Expand Down
27 changes: 21 additions & 6 deletions frontend/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const isChatRoute = pathname === "/chat";
const isDemo = session?.isDemo === true;
const demoCharacter = session?.demoCharacter ?? "bruce";
const handleSidebarClose = useCallback(() => {
setSidebarOpen(false);
}, []);
Expand Down Expand Up @@ -60,29 +61,43 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {

if (!session || session.userStatus !== "approved") return null;

const isBruce = demoCharacter === "bruce";
const demoBorderColor = isBruce ? "border-yellow-700/40" : "border-red-800/40";
const demoBgColor = isBruce ? "bg-yellow-950/95" : "bg-red-950/90";
const demoTextColor = isBruce ? "text-yellow-200/90" : "text-red-200/90";
const demoNameColor = isBruce ? "text-yellow-100" : "text-red-100";
const demoTimerColor = isBruce ? "text-yellow-400" : "text-red-400";
const demoExitBorder = isBruce ? "border-yellow-600/40" : "border-red-600/40";
const demoExitHover = isBruce ? "hover:bg-yellow-500/20 hover:text-yellow-300" : "hover:bg-red-500/20 hover:text-red-300";
const demoNearResetMsg = isBruce
? "⚡ Barry Allen is running around — this timeline will be erased."
: "⚡ The multiverse is collapsing — this timeline will be erased.";
const demoEmoji = isBruce ? "🦇" : "🕷️";
const demoName = isBruce ? "Bruce Wayne" : "Peter Parker";

return (
<div className={`flex h-dvh flex-col overflow-hidden bg-background${isDemo ? (nearReset ? " pt-12" : " pt-8") : ""}`}>
{isDemo && (
<div className="fixed inset-x-0 top-0 z-[60] border-b border-yellow-700/40 bg-yellow-950/95 backdrop-blur-sm">
<div className="flex items-center justify-center gap-3 px-4 py-1.5 text-xs text-yellow-200/90">
<span className="text-sm">🦇</span>
<div className={`fixed inset-x-0 top-0 z-[60] border-b ${demoBorderColor} ${demoBgColor} backdrop-blur-sm`}>
<div className={`flex items-center justify-center gap-3 px-4 py-1.5 text-xs ${demoTextColor}`}>
<span className="text-sm">{demoEmoji}</span>
<span>
Demo mode — browsing as <span className="font-semibold text-yellow-100">Bruce Wayne</span>.
Demo mode — browsing as <span className={`font-semibold ${demoNameColor}`}>{demoName}</span>.
{" "}Resets in{" "}
<span className={`font-mono ${nearReset ? "font-bold text-red-300" : ""}`}>
{formatCountdown(demoSecondsLeft)}
</span>.
</span>
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="ml-1 rounded border border-yellow-600/40 px-2 py-0.5 text-yellow-400 transition-colors hover:bg-yellow-500/20 hover:text-yellow-300"
className={`ml-1 rounded border ${demoExitBorder} px-2 py-0.5 ${demoTimerColor} transition-colors ${demoExitHover}`}
>
Exit demo
</button>
</div>
{nearReset && (
<div className="pb-1.5 text-center text-xs text-yellow-400/70">
⚡ Barry Allen is running around — this timeline will be erased.
{demoNearResetMsg}
</div>
)}
</div>
Expand Down
48 changes: 35 additions & 13 deletions frontend/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function LoginContent() {
const approved = searchParams.get("approved") === "1";
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
const [demoLoading, setDemoLoading] = useState(false);
const [peterLoading, setPeterLoading] = useState(false);

useEffect(() => {
if (session) router.replace("/dashboard");
Expand All @@ -24,6 +25,12 @@ function LoginContent() {
setDemoLoading(false);
};

const handlePeterLogin = async () => {
setPeterLoading(true);
await signIn("demo-peter", { callbackUrl: "/dashboard" });
setPeterLoading(false);
};

if (status === "loading") {
return <div className="flex min-h-screen items-center justify-center"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
}
Expand Down Expand Up @@ -63,22 +70,37 @@ function LoginContent() {
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-card px-3 text-xs text-muted-foreground/60">or</span>
<span className="bg-card px-3 text-xs text-muted-foreground/60">or try a demo account</span>
</div>
</div>

<button
onClick={handleDemoLogin}
disabled={demoLoading}
className="group flex w-full items-center justify-center gap-2.5 rounded-lg border border-yellow-600/30 bg-yellow-950/30 px-4 py-3 text-sm font-medium text-yellow-200/80 transition-colors hover:border-yellow-500/50 hover:bg-yellow-950/50 hover:text-yellow-100 disabled:opacity-60"
>
{demoLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-400/40 border-t-yellow-400" />
) : (
<span className="text-base leading-none transition-transform group-hover:scale-110">🦇</span>
)}
or, login as Bruce Wayne to test the app
</button>
<div className="flex gap-3">
<button
onClick={handleDemoLogin}
disabled={demoLoading || peterLoading}
className="group flex flex-1 items-center justify-center gap-2 rounded-lg border border-yellow-600/30 bg-yellow-950/30 px-3 py-3 text-sm font-medium text-yellow-200/80 transition-colors hover:border-yellow-500/50 hover:bg-yellow-950/50 hover:text-yellow-100 disabled:opacity-60"
>
{demoLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-400/40 border-t-yellow-400" />
) : (
<span className="text-base leading-none transition-transform group-hover:scale-110">🦇</span>
)}
Bruce Wayne
</button>

<button
onClick={handlePeterLogin}
disabled={demoLoading || peterLoading}
className="group flex flex-1 items-center justify-center gap-2 rounded-lg border border-red-700/30 bg-red-950/20 px-3 py-3 text-sm font-medium text-red-200/80 transition-colors hover:border-red-500/50 hover:bg-red-950/40 hover:text-red-100 disabled:opacity-60"
>
{peterLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-red-400/40 border-t-red-400" />
) : (
<span className="text-base leading-none transition-transform group-hover:scale-110">🕷️</span>
)}
Peter Parker
</button>
</div>

<p className="text-center text-xs text-muted-foreground">
By signing in you agree to our{" "}
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,35 @@ const handler = NextAuth({
userStatus: "approved",
isAdmin: false,
isDemo: true,
demoCharacter: "bruce",
};
} catch {
return null;
}
},
}),
CredentialsProvider({
id: "demo-peter",
name: "Demo Peter",
credentials: {},
async authorize() {
try {
const res = await fetch(`${API_URL}/api/auth/demo-login-peter`, { method: "POST" });
if (!res.ok) return null;
const data = (await res.json()) as {
access_token: string;
user: { id: string; email: string; name: string; avatar_url?: string };
};
return {
id: data.user.id,
email: data.user.email,
name: data.user.name,
image: data.user.avatar_url ?? null,
accessToken: data.access_token,
userStatus: "approved",
isAdmin: false,
isDemo: true,
demoCharacter: "peter",
};
} catch {
return null;
Expand All @@ -68,12 +97,13 @@ const handler = NextAuth({
trigger?: string;
}) {
// Demo credentials sign-in: backend JWT already in user object
if (account?.provider === "demo" && user) {
if ((account?.provider === "demo" || account?.provider === "demo-peter") && user) {
token.accessToken = (user as User & { accessToken?: string }).accessToken;
token.avatar_url = user.image ?? undefined;
token.userStatus = "approved";
token.isAdmin = false;
token.isDemo = true;
token.demoCharacter = (user as User & { demoCharacter?: string }).demoCharacter;
return token;
}

Expand Down Expand Up @@ -125,6 +155,7 @@ const handler = NextAuth({
session.userStatus = token.userStatus;
session.isAdmin = token.isAdmin;
session.isDemo = token.isDemo;
session.demoCharacter = token.demoCharacter;
if (session.user) {
session.user.avatar_url = token.avatar_url;
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ declare module "next-auth" {
userStatus?: string;
isAdmin?: boolean;
isDemo?: boolean;
demoCharacter?: string;
user: {
id?: string;
avatar_url?: string;
Expand All @@ -23,6 +24,7 @@ declare module "next-auth" {
userStatus?: string;
isAdmin?: boolean;
isDemo?: boolean;
demoCharacter?: string;
}
}

Expand All @@ -33,5 +35,6 @@ declare module "next-auth/jwt" {
userStatus?: string;
isAdmin?: boolean;
isDemo?: boolean;
demoCharacter?: string;
}
}
Loading