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
2 changes: 1 addition & 1 deletion src/app/api/settings/require-login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function hasConfiguredPassword(settings: Record<string, unknown>) {
}

function isBootstrapSecurityWindow(settings: Record<string, unknown>) {
return settings.setupComplete !== true && !hasConfiguredPassword(settings);
return !hasConfiguredPassword(settings);
}

export async function GET() {
Expand Down
239 changes: 121 additions & 118 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,34 +87,37 @@ export default function LoginPage() {
}
};

const nodeWarningBanner = !nodeCompatible && nodeVersion ? (
<div className="w-full max-w-lg mx-auto mb-6 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="bg-red-950/60 border-2 border-red-500/40 rounded-2xl p-6 shadow-lg shadow-red-900/20 backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-red-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="material-symbols-outlined text-red-400 text-[28px]">error</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-red-300 mb-1">{t("nodeIncompatibleTitle")}</h3>
<p className="text-sm text-red-200/80 leading-relaxed mb-3">
{t("nodeIncompatibleDesc", { version: nodeVersion })}
</p>
<div className="bg-black/40 rounded-lg px-4 py-3 font-mono text-sm border border-red-500/20">
<div className="flex items-center gap-2 text-red-300/60 mb-1">
<span className="material-symbols-outlined text-[14px]">terminal</span>
<span className="text-xs">{t("nodeIncompatibleFixLabel")}</span>
const nodeWarningBanner =
!nodeCompatible && nodeVersion ? (
<div className="w-full max-w-lg mx-auto mb-6 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="bg-red-950/60 border-2 border-red-500/40 rounded-2xl p-6 shadow-lg shadow-red-900/20 backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-red-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="material-symbols-outlined text-red-400 text-[28px]">error</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-red-300 mb-1">
{t("nodeIncompatibleTitle")}
</h3>
<p className="text-sm text-red-200/80 leading-relaxed mb-3">
{t("nodeIncompatibleDesc", { version: nodeVersion })}
</p>
<div className="bg-black/40 rounded-lg px-4 py-3 font-mono text-sm border border-red-500/20">
<div className="flex items-center gap-2 text-red-300/60 mb-1">
<span className="material-symbols-outlined text-[14px]">terminal</span>
<span className="text-xs">{t("nodeIncompatibleFixLabel")}</span>
</div>
<code className="text-amber-300">nvm install 22 && nvm use 22</code>
</div>
<code className="text-amber-300">nvm install 22 && nvm use 22</code>
<p className="text-xs text-red-300/50 mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
{t("nodeIncompatibleHint")}
</p>
</div>
<p className="text-xs text-red-300/50 mt-3 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
{t("nodeIncompatibleHint")}
</p>
</div>
</div>
</div>
</div>
) : null;
) : null;

if (hasPassword === null || setupComplete === null) {
return (
Expand Down Expand Up @@ -194,7 +197,7 @@ export default function LoginPage() {
<Button
variant="primary"
className="w-full h-11 text-sm font-medium"
onClick={() => router.push("/dashboard/settings?tab=security")}
onClick={() => router.push("/dashboard/onboarding")}
>
{t("configurePassword")}
</Button>
Expand All @@ -212,116 +215,116 @@ export default function LoginPage() {
return (
<div className="min-h-screen flex flex-col bg-bg">
{nodeWarningBanner && (
<div className="flex justify-center pt-6 px-6">
{nodeWarningBanner}
</div>
<div className="flex justify-center pt-6 px-6">{nodeWarningBanner}</div>
)}
<div className="flex-1 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 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>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
</div>

<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{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>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
className="h-11"
/>
{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>
)}
<p className="text-xs text-text-muted/60 pt-0.5">{t("defaultPasswordHint")}</p>
</div>

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

<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"
>
{t("forgotPassword")}
</a>
<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"
>
{t("forgotPassword")}
</a>
</div>
</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">{t("unifiedAiApiProxy")}</h2>
<p className="text-text-muted leading-relaxed">{t("unifiedAiApiProxyDesc")}</p>
</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">{t("unifiedAiApiProxy")}</h2>
<p className="text-text-muted leading-relaxed">{t("unifiedAiApiProxyDesc")}</p>
</div>

<div className="space-y-4">
{[
{
icon: "swap_horiz",
title: t("featureMultiProviderTitle"),
desc: t("featureMultiProviderDesc"),
},
{
icon: "speed",
title: t("featureLoadBalancingTitle"),
desc: t("featureLoadBalancingDesc"),
},
{
icon: "analytics",
title: t("featureUsageTrackingTitle"),
desc: t("featureUsageTrackingDesc"),
},
].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 className="space-y-4">
{[
{
icon: "swap_horiz",
title: t("featureMultiProviderTitle"),
desc: t("featureMultiProviderDesc"),
},
{
icon: "speed",
title: t("featureLoadBalancingTitle"),
desc: t("featureLoadBalancingDesc"),
},
{
icon: "analytics",
title: t("featureUsageTrackingTitle"),
desc: t("featureUsageTrackingDesc"),
},
].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>
</div>
</div>
</div>
);
}
6 changes: 2 additions & 4 deletions src/shared/constants/publicApiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ const PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/settings/require-login",
"/api/v1/",
"/api/cloud/",
"/api/sync/bundle",
"/api/oauth/",
];

const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
"/api/monitoring/health",
"/api/settings/require-login",
];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health"];

const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

Expand Down
8 changes: 8 additions & 0 deletions src/shared/utils/apiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ function getRequestPathname(request: RequestLike | Request | null | undefined):
}
}

function isOnboardingBootstrapPath(pathname: string | null): boolean {
return pathname === "/dashboard/onboarding";
}

function getRequestMethod(request: RequestLike | Request | null | undefined): string {
if (
request &&
Expand Down Expand Up @@ -273,6 +277,10 @@ export async function isAuthRequired(
if (!request) return false;

const pathname = getRequestPathname(request);
if (isOnboardingBootstrapPath(pathname)) {
return false;
}

if (pathname && isPublicApiRoute(pathname, getRequestMethod(request))) {
return false;
}
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/authz/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ test("runAuthzPipeline redirects unauthenticated dashboard pages to login", asyn
assert.ok(response.headers.get("x-request-id"));
});

test("runAuthzPipeline allows onboarding when login is required but no password exists", async () => {
delete process.env.INITIAL_PASSWORD;
await settingsDb.updateSettings({
requireLogin: true,
setupComplete: true,
password: "",
});

const response = await pipeline.runAuthzPipeline(
request("https://example.com/dashboard/onboarding"),
{ enforce: true }
);

assert.equal(response.status, 200);
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
});

test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => {
delete process.env.INITIAL_PASSWORD;
await settingsDb.updateSettings({
requireLogin: true,
setupComplete: true,
password: "",
});

const response = await pipeline.runAuthzPipeline(
request("https://example.com/api/settings/require-login", { method: "POST" }),
{ enforce: true }
);

assert.equal(response.status, 200);
assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC");
});

test("runAuthzPipeline keeps management API rejections as JSON", async () => {
await forceAuthRequired();

Expand Down
Loading