mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
Fix/auto generate jwt secret (#94)
* fix: auto-generate JWT_SECRET at startup if not provided - Add ensureJwtSecret() in instrumentation.ts to generate random 64-char secret - Mark JWT_SECRET as non-required in secretsValidator - Remove fatal error log for missing JWT_SECRET in proxy.ts Fixes login failure when running via CLI without JWT_SECRET env var. * feat: improve auth flow and login page UX - Add setupComplete to settings validation schema for proper persistence - Support ?tab= query param in settings page for direct tab navigation - Redesign login page with professional two-column layout - Add context-aware states for onboarding/password setup flows - Smooth animations and refined visual hierarchy * fix: 404 page to use primary color theme (coral red) instead of purple
This commit is contained in:
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"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() {
|
||||
@@ -9,9 +9,12 @@ export default function LoginPage() {
|
||||
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);
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{!hasPassword && (
|
||||
<p className="text-xs text-center text-text-muted mt-2">
|
||||
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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're looking for doesn'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
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 || "");
|
||||
|
||||
export async function proxy(request) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ export const updateSettingsSchema = z.object({
|
||||
logRetentionDays: z.number().int().min(1).max(365).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Auth Schemas ────
|
||||
|
||||
Reference in New Issue
Block a user