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
8 changes: 6 additions & 2 deletions src/app/(dashboard)/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import SystemStorageTab from "./components/SystemStorageTab";
Expand All @@ -25,7 +26,10 @@ const tabs = [
];

export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("general");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const [userSelectedTab, setUserSelectedTab] = useState(null);
const activeTab = userSelectedTab || tabs.find((t) => t.id === tabParam)?.id || "general";
Comment thread
StealthIQ marked this conversation as resolved.

return (
<div className="max-w-2xl mx-auto">
Expand All @@ -42,7 +46,7 @@ export default function SettingsPage() {
role="tab"
aria-selected={activeTab === tab.id}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onClick={() => setUserSelectedTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md font-medium transition-all text-sm",
activeTab === tab.id
Expand Down
220 changes: 187 additions & 33 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"use client";

import { useState, useEffect } from "react";
import { Card, Button, Input } from "@/shared/components";
import { Button, Input } from "@/shared/components";
import { useRouter } from "next/navigation";

export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [hasPassword, setHasPassword] = useState(null);
const [setupComplete, setSetupComplete] = useState(null);
const [mounted, setMounted] = useState(false);
const router = useRouter();

useEffect(() => {
setMounted(true);
async function checkAuth() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
Expand All @@ -31,13 +34,15 @@ export default function LoginPage() {
return;
}
setHasPassword(!!data.hasPassword);
setSetupComplete(!!data.setupComplete);
} else {
// Safe fallback on non-OK response to avoid infinite loading state.
setHasPassword(true);
setSetupComplete(true);
}
} catch (err) {
clearTimeout(timeoutId);
setHasPassword(true);
setSetupComplete(true);
}
}
checkAuth();
Expand Down Expand Up @@ -70,58 +75,207 @@ export default function LoginPage() {
}
};

// Show loading state while checking password
if (hasPassword === null) {
if (hasPassword === null || setupComplete === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-text-muted mt-4">Loading...</p>
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="flex flex-col items-center gap-3">
<div className="relative">
<div className="w-10 h-10 border-2 border-primary/20 rounded-full"></div>
<div className="absolute inset-0 w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<span className="text-sm text-text-muted">Loading...</span>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">OmniRoute</h1>
<p className="text-text-muted">Enter your password to access the dashboard</p>
if (!hasPassword && !setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/10 mb-6">
<span className="material-symbols-outlined text-primary text-[40px]">
rocket_launch
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">Welcome</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
</div>

<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Run the onboarding wizard to set up your password and connect your first AI
provider.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/onboarding")}
>
Start Onboarding
</Button>
</div>
</div>

<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute — Unified AI API Proxy
</p>
</div>
</div>
);
}

if (!hasPassword && setupComplete) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg p-6">
<div
className={`w-full max-w-md transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-amber-500/10 to-amber-500/5 border border-amber-500/10 mb-6">
<span className="material-symbols-outlined text-amber-500 text-[40px]">
shield_person
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">
Secure Your Instance
</h1>
<p className="text-text-muted mt-2">Password protection is not enabled</p>
</div>

<div className="bg-surface border border-border rounded-2xl p-8 shadow-soft">
<div className="text-center">
<p className="text-text-muted leading-relaxed mb-6">
Set a password to protect your dashboard and secure your API endpoints from
unauthorized access.
</p>
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/settings?tab=security")}
>
Configure Password
</Button>
</div>
</div>

<p className="text-center text-xs text-text-muted/60 mt-8">
OmniRoute — Unified AI API Proxy
</p>
</div>
</div>
);
}

<Card>
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Password</label>
return (
<div className="min-h-screen flex bg-bg">
<div className="flex-1 flex items-center justify-center p-6">
<div
className={`w-full max-w-sm transition-all duration-700 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="mb-10">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
</div>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">Sign in</h1>
<p className="text-text-muted mt-1.5">Enter your password to continue</p>
</div>

<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">Password</label>
<Input
type="password"
placeholder="Enter password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
{error && (
<p className="text-sm text-red-500 flex items-center gap-1.5 pt-1">
<span className="material-symbols-outlined text-base">error</span>
{error}
</p>
)}
</div>

<Button type="submit" variant="primary" className="w-full" loading={loading}>
Login
<Button
type="submit"
variant="primary"
className="w-full h-11 text-sm font-medium"
loading={loading}
>
Continue
</Button>
</form>

{!hasPassword && (
<p className="text-xs text-center text-text-muted mt-2">
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
<div className="mt-6 pt-6 border-t border-border">
<a
href="/forgot-password"
className="text-sm text-text-muted hover:text-primary transition-colors"
>
Forgot your password?
</a>
</div>
</div>
</div>

<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary/5 via-primary/3 to-transparent items-center justify-center p-12">
<div
className={`max-w-md transition-all duration-700 delay-200 ease-out ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-text-main mb-3">Unified AI API Proxy</h2>
<p className="text-text-muted leading-relaxed">
Route requests to multiple AI providers through a single endpoint. Load balancing,
failover, and usage tracking built in.
</p>
)}
</div>

<p className="text-xs text-center mt-1">
<a href="/forgot-password" className="text-primary hover:underline">
Forgot password?
</a>
</p>
</form>
</Card>
<div className="space-y-4">
{[
{
icon: "swap_horiz",
title: "Multi-Provider",
desc: "OpenAI, Anthropic, Google, and more",
},
{
icon: "speed",
title: "Load Balancing",
desc: "Distribute requests intelligently",
},
{ icon: "analytics", title: "Usage Tracking", desc: "Monitor costs and tokens" },
].map((item) => (
<div
key={item.icon}
className="flex items-start gap-4 p-4 rounded-xl bg-surface/50 border border-border"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<span className="material-symbols-outlined text-primary text-[20px]">
{item.icon}
</span>
</div>
<div>
<h3 className="font-medium text-text-main">{item.title}</h3>
<p className="text-sm text-text-muted">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
Expand Down
19 changes: 5 additions & 14 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,29 @@
"use client";

/**
* Custom Not Found Page — FASE-04 Error Handling
*
* Displayed when a user navigates to a non-existent route.
*/

import Link from "next/link";

export default function NotFound() {
return (
<div
className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center"
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center"
role="main"
aria-labelledby="not-found-title"
>
<div
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-[#6366f1] via-[#8b5cf6] to-[#a855f7] bg-clip-text text-transparent"
className="text-[96px] font-extrabold leading-none mb-2 bg-gradient-to-br from-primary to-primary-hover bg-clip-text text-transparent"
aria-hidden="true"
>
404
</div>
<h1
id="not-found-title"
className="text-2xl font-semibold mb-2"
>
<h1 id="not-found-title" className="text-2xl font-semibold mb-2">
Page not found
</h1>
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/dashboard"
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5 bg-gradient-to-br from-[#6366f1] to-[#8b5cf6] focus:outline-2 focus:outline-offset-2 focus:outline-[#6366f1]"
className="px-8 py-3 rounded-xl text-white text-sm font-medium no-underline transition-all duration-200 shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Return to dashboard"
>
Go to Dashboard
Expand Down
11 changes: 11 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/

import crypto from "crypto";

function ensureJwtSecret(): void {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.trim() === "") {
const generated = crypto.randomBytes(48).toString("base64");
process.env.JWT_SECRET = generated;
console.log("[STARTUP] JWT_SECRET auto-generated (random 64-char secret)");
}
Comment on lines +14 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The auto-generated secret is stored in process.env at runtime, which presents two major issues: 1. Persistence: The secret is lost on every server restart, invalidating all active user sessions and forcing logouts. 2. Propagation: In Next.js, environment variables modified in the Node.js runtime of instrumentation.ts do not reliably propagate to the Middleware (which often runs in the Edge runtime or a separate process). This means the Middleware might still see an undefined secret, leading to the authentication errors this PR intends to fix. Consider using a persistent storage mechanism or a stable machine-specific seed for zero-config setups.

}

export async function register() {
// Only run on the server (not during build or in Edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
ensureJwtSecret();
// Console log file capture (must be first — before any logging occurs)
const { initConsoleInterceptor } = await import("@/lib/consoleInterceptor");
initConsoleInterceptor();
Expand Down
7 changes: 1 addition & 6 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@ import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAut
import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard";
import { isDraining } from "./lib/gracefulShutdown";

// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
if (!process.env.JWT_SECRET) {
console.error("[SECURITY] JWT_SECRET is not set. Authentication will fail.");
}

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The use of an empty string ("") as a fallback for the JWT_SECRET in src/proxy.ts is a critical vulnerability. If process.env.JWT_SECRET is missing or fails to propagate, the SECRET constant will be initialized with an empty value. This allows attackers to bypass authentication by forging tokens signed with an empty key, violating the "Fail Securely" principle. The secret should be loaded and validated within the request lifecycle, not at the module level. Initialize the secret inside the proxy function and add a check to ensure it is not empty, failing the request if it is.


export async function proxy(request) {
const { pathname } = request.nextUrl;
Expand Down
4 changes: 2 additions & 2 deletions src/shared/utils/secretsValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const SECRET_RULES = [
{
name: "JWT_SECRET",
minLength: 32,
required: true,
description: "JWT signing secret for dashboard authentication",
required: false,
description: "JWT signing secret for dashboard authentication (auto-generated if not set)",
generateHint: "openssl rand -base64 48",
},
{
Expand Down
Loading