Skip to content
This repository was archived by the owner on Mar 24, 2026. It is now read-only.
Open
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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ FROM node:20-alpine AS builder

WORKDIR /app

# Optional build-time basePath (e.g. /vexa)
ARG NEXT_PUBLIC_BASE_PATH
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH

# Install dependencies
COPY package*.json ./
RUN npm ci
Expand Down
7 changes: 7 additions & 0 deletions build_image.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env bash

VERSION="v0.6.0"
BASE_REPO=""

docker build --build-arg NEXT_PUBLIC_BASE_PATH=/vexa -t "${BASE_REPO}/vexa/vexa-dashboard:${VERSION}" --build-arg NEXT_PUBLIC_BASE_PATH=/vexa .
docker push "${BASE_REPO}/vexa/vexa-dashboard:${VERSION}"
10 changes: 10 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import type { NextConfig } from "next";
import path from "path";

const normalizeBasePath = (value?: string) => {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
};

const basePath = normalizeBasePath(process.env.NEXT_PUBLIC_BASE_PATH);

const nextConfig: NextConfig = {
// Only use standalone output for production builds
...(process.env.NODE_ENV === 'production' ? { output: 'standalone' } : {}),
...(basePath ? { basePath, assetPrefix: basePath } : {}),
// Ensure Turbopack uses this project as root
// (avoids picking a parent lockfile and serving nothing)
turbopack: {
Expand Down
58 changes: 55 additions & 3 deletions src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADProvider from "next-auth/providers/azure-ad";
import { cookies } from "next/headers";
import { findUserByEmail, createUser, createUserToken } from "@/lib/vexa-admin-api";
import { getRegistrationConfig, validateEmailForRegistration } from "@/lib/registration";
Expand Down Expand Up @@ -28,6 +29,45 @@ const isGoogleAuthEnabled = () => {
return hasConfig;
};

const isAzureAdAuthEnabled = () => {
const enableAzureAdAuth = process.env.ENABLE_AZURE_AD_AUTH;
if (enableAzureAdAuth === "false" || enableAzureAdAuth === "0") {
return false;
}

const hasConfig = !!(
process.env.AZURE_AD_CLIENT_ID &&
process.env.AZURE_AD_CLIENT_SECRET &&
process.env.AZURE_AD_TENANT_ID &&
process.env.NEXTAUTH_URL
);

if (enableAzureAdAuth === "true" || enableAzureAdAuth === "1") {
return hasConfig;
}

return hasConfig;
};

const getAppBasePath = (): string => {
const rawUrl = process.env.NEXTAUTH_URL;
if (!rawUrl) return "";
try {
const path = new URL(rawUrl).pathname.replace(/\/$/, "");
return path.endsWith("/api/auth") ? path.slice(0, -"/api/auth".length) : path;
} catch {
return "";
}
};

const buildAppPath = (suffix: string): string => {
const basePath = getAppBasePath();
if (!basePath || basePath === "/") {
return suffix;
}
return `${basePath}${suffix}`;
};

export const authOptions: NextAuthOptions = {
providers: [
...(isGoogleAuthEnabled()
Expand All @@ -38,15 +78,27 @@ export const authOptions: NextAuthOptions = {
}),
]
: []),
...(isAzureAdAuthEnabled()
? [
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID!,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
tenantId: process.env.AZURE_AD_TENANT_ID!,
}),
]
: []),
],
pages: {
signIn: "/login",
error: "/login",
signIn: buildAppPath("/login"),
error: buildAppPath("/login"),
},
callbacks: {
async signIn({ user, account, profile }) {
// This callback is called after successful OAuth but before session creation
if (account?.provider === "google" && user.email) {
if (
(account?.provider === "google" || account?.provider === "azure-ad") &&
user.email
) {
try {
// Step 1: Find or create user in Vexa Admin API
let vexaUser;
Expand Down
3 changes: 2 additions & 1 deletion src/app/auth/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Button } from "@/components/ui/button";
import { Logo } from "@/components/ui/logo";
import { useAuthStore } from "@/stores/auth-store";
import { withBasePath } from "@/lib/base-path";

type VerifyState = "verifying" | "success" | "error";

Expand Down Expand Up @@ -57,7 +58,7 @@ function VerifyContent() {
}

try {
const response = await fetch("/api/auth/verify", {
const response = await fetch(withBasePath("/api/auth/verify"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
5 changes: 3 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { AppLayout } from "@/components/layout/app-layout";
import { TooltipProvider } from "@/components/ui/tooltip";
import { withBasePath } from "@/lib/base-path";
import "./globals.css";

const geistSans = Geist({
Expand All @@ -21,13 +22,13 @@ export const metadata: Metadata = {
icons: {
icon: [
{
url: "/icons/vexadark.svg",
url: withBasePath("/icons/vexadark.svg"),
type: "image/svg+xml",
},
],
apple: [
{
url: "/icons/vexadark.svg",
url: withBasePath("/icons/vexadark.svg"),
type: "image/svg+xml",
},
],
Expand Down
131 changes: 92 additions & 39 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { Mail, Loader2, CheckCircle, ArrowLeft, AlertTriangle, XCircle } from "lucide-react";
import { Logo } from "@/components/ui/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Expand All @@ -11,16 +10,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAuthStore } from "@/stores/auth-store";
import { withBasePath } from "@/lib/base-path";
import { toast } from "sonner";

type LoginState = "email" | "sent";

interface HealthStatus {
status: "ok" | "degraded" | "error";
authMode: "direct" | "magic-link" | "google";
authMode: "direct" | "magic-link" | "google" | "entra-id";
checks: {
smtp: { configured: boolean; optional?: boolean; error?: string };
googleOAuth: { configured: boolean; optional?: boolean; error?: string };
azureAdOAuth: { configured: boolean; optional?: boolean; error?: string };
adminApi: { configured: boolean; reachable: boolean; error?: string };
vexaApi: { configured: boolean; reachable: boolean; error?: string };
};
Expand All @@ -45,7 +46,7 @@ export default function LoginPage() {
useEffect(() => {
const checkHealth = async () => {
try {
const response = await fetch("/api/health");
const response = await fetch(withBasePath("/api/health"));
const data = await response.json();
setHealthStatus(data);
} catch {
Expand All @@ -55,6 +56,7 @@ export default function LoginPage() {
checks: {
smtp: { configured: false, optional: true, error: "Cannot reach server" },
googleOAuth: { configured: false, optional: true, error: "Cannot reach server" },
azureAdOAuth: { configured: false, optional: true, error: "Cannot reach server" },
adminApi: { configured: false, reachable: false, error: "Cannot reach server" },
vexaApi: { configured: false, reachable: false, error: "Cannot reach server" },
},
Expand Down Expand Up @@ -107,23 +109,55 @@ export default function LoginPage() {
setState("email");
};

const signInWithProvider = async (providerId: "google" | "azure-ad") => {
const callbackUrl = withBasePath("/");
const csrfResponse = await fetch(withBasePath("/api/auth/csrf"));
if (!csrfResponse.ok) {
throw new Error("Failed to fetch CSRF token");
}
const { csrfToken } = (await csrfResponse.json()) as { csrfToken: string };

const signInResponse = await fetch(withBasePath(`/api/auth/signin/${providerId}`), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
csrfToken,
callbackUrl,
json: "true",
}),
});

const data = (await signInResponse.json()) as { url?: string };
window.location.href = data.url || callbackUrl;
};

const handleGoogleSignIn = async () => {
try {
await signIn("google", {
callbackUrl: "/",
redirect: true,
});
await signInWithProvider("google");
} catch (error) {
console.error("Google sign-in error:", error);
toast.error("Failed to sign in with Google");
}
};

const handleAzureAdSignIn = async () => {
try {
await signInWithProvider("azure-ad");
} catch (error) {
console.error("Azure AD sign-in error:", error);
toast.error("Failed to sign in with Microsoft");
}
};

const isConfigError = healthStatus?.status === "error";
const hasWarnings = healthStatus?.status === "degraded";
const isDirectMode = healthStatus?.authMode === "direct";
const isGoogleAuthEnabled = healthStatus?.checks.googleOAuth.configured === true;
const isEmailAuthEnabled = !isGoogleAuthEnabled && (healthStatus?.authMode === "magic-link" || healthStatus?.authMode === "direct");
const isAzureAdAuthEnabled = healthStatus?.checks.azureAdOAuth.configured === true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Badge Guard Azure health checks before reading configured flag

The login page now dereferences healthStatus?.checks.azureAdOAuth.configured, but /api/health still returns a checks object without azureAdOAuth (it only includes smtp, googleOAuth, adminApi, and vexaApi). In the normal success path this throws Cannot read properties of undefined during render, crashing the login screen and blocking all sign-in flows. Either make this access optional (azureAdOAuth?.configured) or update the health endpoint payload to always include that field.

Useful? React with 👍 / 👎.

const isOAuthEnabled = isGoogleAuthEnabled || isAzureAdAuthEnabled;
const isEmailAuthEnabled = !isOAuthEnabled && (healthStatus?.authMode === "magic-link" || healthStatus?.authMode === "direct");

return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30 p-4">
Expand Down Expand Up @@ -180,43 +214,62 @@ export default function LoginPage() {
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome</CardTitle>
<CardDescription>
{isGoogleAuthEnabled
{isOAuthEnabled
? "Sign in to continue"
: isDirectMode
? "Enter your email to sign in"
: "Enter your email to receive a sign-in link"}
</CardDescription>
</CardHeader>
<CardContent>
{isGoogleAuthEnabled && (
{isOAuthEnabled && (
<>
<Button
type="button"
onClick={handleGoogleSignIn}
className="w-full"
disabled={isConfigError}
variant="default"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
{isGoogleAuthEnabled && (
<Button
type="button"
onClick={handleGoogleSignIn}
className="w-full"
disabled={isConfigError}
variant="default"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
)}
{isAzureAdAuthEnabled && (
<Button
type="button"
onClick={handleAzureAdSignIn}
className="w-full mt-3"
disabled={isConfigError}
variant="default"
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path fill="#F25022" d="M1 1h10v10H1z" />
<path fill="#7FBA00" d="M13 1h10v10H13z" />
<path fill="#00A4EF" d="M1 13h10v10H1z" />
<path fill="#FFB900" d="M13 13h10v10H13z" />
</svg>
Sign in with Microsoft
</Button>
)}
{isEmailAuthEnabled && (
<>
<div className="relative my-4">
Expand Down Expand Up @@ -246,7 +299,7 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
disabled={isLoading || isConfigError}
autoFocus={!isGoogleAuthEnabled}
autoFocus={!isOAuthEnabled}
/>
</div>
</div>
Expand All @@ -255,7 +308,7 @@ export default function LoginPage() {
type="submit"
className="w-full"
disabled={isLoading || healthLoading || isConfigError}
variant={isGoogleAuthEnabled ? "outline" : "default"}
variant={isOAuthEnabled ? "outline" : "default"}
>
{healthLoading ? (
<>
Expand Down
Loading