diff --git a/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx b/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx
index 998b5dc68..8eb4c946d 100644
--- a/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx
+++ b/src/app/(dashboard)/(modules)/communications/templates/[_id]/page.tsx
@@ -7,7 +7,7 @@ import {
getCommunicationTemplate,
updateCommunicationTemplate,
} from '@/lib/api/communications/templates';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
index 3df79cd82..53dd690a7 100644
--- a/src/app/global-error.tsx
+++ b/src/app/global-error.tsx
@@ -5,6 +5,10 @@ import { AlertTriangle } from 'lucide-react';
const inter = Inter({ subsets: ['latin'] });
+const handleGoHome = () => {
+ window.location.assign('/');
+};
+
export default function GlobalError({
error,
reset,
@@ -47,12 +51,13 @@ export default function GlobalError({
>
Try Again
-
Go Home
-
+
diff --git a/src/components/communications/templates/add-channels-dialog.tsx b/src/components/communications/templates/add-channels-dialog.tsx
index f18f95a70..a0465af91 100644
--- a/src/components/communications/templates/add-channels-dialog.tsx
+++ b/src/components/communications/templates/add-channels-dialog.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
import { MigrationResponse } from '@/lib/models/communications/template-row';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
import {
Dialog,
DialogContent,
diff --git a/src/components/communications/templates/bulk-add-channels-button.tsx b/src/components/communications/templates/bulk-add-channels-button.tsx
index 8d3161385..c0991b7bc 100644
--- a/src/components/communications/templates/bulk-add-channels-button.tsx
+++ b/src/components/communications/templates/bulk-add-channels-button.tsx
@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
import {
AlertDialog,
AlertDialogAction,
diff --git a/src/components/communications/templates/create-template-sheet.tsx b/src/components/communications/templates/create-template-sheet.tsx
index 3e203a145..46702a50f 100644
--- a/src/components/communications/templates/create-template-sheet.tsx
+++ b/src/components/communications/templates/create-template-sheet.tsx
@@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { useToast } from '@/lib/hooks/use-toast';
import { createCommunicationTemplate } from '@/lib/api/communications/templates';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
import {
CommunicationTemplateForm,
CommunicationTemplateFormValues,
diff --git a/src/components/communications/templates/email-template-detail.tsx b/src/components/communications/templates/email-template-detail.tsx
index 1a0839567..3347825e0 100644
--- a/src/components/communications/templates/email-template-detail.tsx
+++ b/src/components/communications/templates/email-template-detail.tsx
@@ -2,7 +2,7 @@
import { getTemplates } from '@/lib/api/email';
import { migrateFromEmailTemplate } from '@/lib/api/communications/templates';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
import React, { useEffect, useState } from 'react';
import { EmailTemplate } from '@/lib/models/email';
import { Button } from '@/components/ui/button';
diff --git a/src/components/navigation/navEnv.tsx b/src/components/navigation/navEnv.tsx
index 76e2b1e1b..cceae9e3f 100644
--- a/src/components/navigation/navEnv.tsx
+++ b/src/components/navigation/navEnv.tsx
@@ -28,11 +28,9 @@ export function NavEnv() {
const router = useRouter();
useEffect(() => {
getEnvNames().then(res => {
- 'use client';
setAvailableEnvs(res);
});
getEnvName().then(env => {
- 'use client';
setEnv(env);
});
}, []);
diff --git a/src/lib/api/api.ts b/src/lib/api/api.ts
index e6a7d5097..cf4ef9935 100644
--- a/src/lib/api/api.ts
+++ b/src/lib/api/api.ts
@@ -37,8 +37,17 @@ export const getApiClient = async (env?: string) => {
return response;
},
async error => {
- const { response } = error;
- if (response?.status === 401 && error.request.path !== '/admin/login') {
+ const { response, config } = error;
+ const url = config?.url ?? '';
+ const isAuthAttempt = url === '/login' || url === '/verify-twofa';
+ const cookieStore = await cookies();
+ const activeEnv = cookieStore.get('activeEnv')?.value ?? 'Local';
+ const envDetails = await _getEnv(activeEnv);
+ const hasSessionCookie = Boolean(
+ cookieStore.get(`${envDetails.name}AccessToken`)?.value
+ );
+
+ if (response?.status === 401 && !isAuthAttempt && hasSessionCookie) {
redirect('/login?session-timeout=true', RedirectType.replace);
}
if (error.response) {
diff --git a/src/lib/api/auth/cookie-options.ts b/src/lib/api/auth/cookie-options.ts
new file mode 100644
index 000000000..72a44c41d
--- /dev/null
+++ b/src/lib/api/auth/cookie-options.ts
@@ -0,0 +1,34 @@
+import jwt from 'jsonwebtoken';
+
+const DEFAULT_COOKIE_MAX_AGE = 72000;
+
+export type SessionCookieOptions = {
+ value: string;
+ httpOnly: true;
+ maxAge: number;
+ secure: boolean;
+ sameSite: 'lax';
+};
+
+export function getCookieMaxAgeFromToken(token: string): number {
+ const decoded = jwt.decode(token) as { exp?: number } | null;
+ if (decoded?.exp) {
+ return Math.max(decoded.exp - Math.floor(Date.now() / 1000), 0);
+ }
+ return DEFAULT_COOKIE_MAX_AGE;
+}
+
+export function buildSessionCookieOptions(
+ value: string,
+ tokenForMaxAge?: string
+): SessionCookieOptions {
+ return {
+ value,
+ httpOnly: true,
+ maxAge: tokenForMaxAge
+ ? getCookieMaxAgeFromToken(tokenForMaxAge)
+ : DEFAULT_COOKIE_MAX_AGE,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ };
+}
diff --git a/src/lib/api/auth/index.ts b/src/lib/api/auth/index.ts
index 371626ef3..730a8baba 100644
--- a/src/lib/api/auth/index.ts
+++ b/src/lib/api/auth/index.ts
@@ -2,6 +2,8 @@
import { getApiClient } from '@/lib/api';
import { cookies } from 'next/headers';
import jwt from 'jsonwebtoken';
+import { buildSessionCookieOptions } from '@/lib/api/auth/cookie-options';
+import { getEnvironment } from '@/lib/logic/EnvManager';
export const adminLogin = async (
username: string,
@@ -15,17 +17,15 @@ export const adminLogin = async (
const decodedToken = jwt.decode(token) as { twoFaRequired?: boolean };
const twoFaRequired = decodedToken?.twoFaRequired || false;
- (await cookies()).set({
- name: `${environment}AccessToken`,
- value: res.data.token,
- httpOnly: true,
- maxAge: 72000,
+ const envDetails = await getEnvironment(environment);
+ const cookieStore = await cookies();
+ cookieStore.set({
+ name: `${envDetails.name}AccessToken`,
+ ...buildSessionCookieOptions(token, token),
});
- (await cookies()).set({
+ cookieStore.set({
name: `activeEnv`,
- value: environment,
- httpOnly: true,
- maxAge: 72000,
+ ...buildSessionCookieOptions(envDetails.name, token),
});
return twoFaRequired;
};
@@ -35,11 +35,16 @@ export const loginSecondFactor = async (code: string, environment: string) => {
await getApiClient(environment)
).post('/verify-twofa', { code });
- (await cookies()).set({
- name: `${environment}AccessToken`,
- value: res.data.result,
- httpOnly: true,
- maxAge: 72000,
+ const token = res.data.result;
+ const envDetails = await getEnvironment(environment);
+ const cookieStore = await cookies();
+ cookieStore.set({
+ name: `${envDetails.name}AccessToken`,
+ ...buildSessionCookieOptions(token, token),
+ });
+ cookieStore.set({
+ name: `activeEnv`,
+ ...buildSessionCookieOptions(envDetails.name, token),
});
return 'OK';
};
@@ -52,15 +57,20 @@ export const getAdmin = async () => {
};
export const adminLogout = async () => {
- const activeEnv = (await cookies()).get('activeEnv')!;
- (await cookies()).delete(`${activeEnv.value}AccessToken`);
- (await cookies()).delete(`activeEnv`);
+ const cookieStore = await cookies();
+ const activeEnv = cookieStore.get('activeEnv');
+ if (!activeEnv?.value) return;
+ const envDetails = await getEnvironment(activeEnv.value);
+ cookieStore.delete(`${envDetails.name}AccessToken`);
+ cookieStore.delete(`activeEnv`);
};
+
export const switchEnv = async (env: string) => {
- (await cookies()).set({
+ const envDetails = await getEnvironment(env);
+ const cookieStore = await cookies();
+ const accessToken = cookieStore.get(`${envDetails.name}AccessToken`)?.value;
+ cookieStore.set({
name: `activeEnv`,
- value: env,
- httpOnly: true,
- maxAge: 72000,
+ ...buildSessionCookieOptions(envDetails.name, accessToken),
});
};
diff --git a/src/lib/api/communications/templates.ts b/src/lib/api/communications/templates.ts
index 08137924b..719dbb1f4 100644
--- a/src/lib/api/communications/templates.ts
+++ b/src/lib/api/communications/templates.ts
@@ -6,7 +6,7 @@ import {
CommunicationTemplatePayload,
} from '@/lib/models/communications/templates';
import { MigrationResponse } from '@/lib/models/communications/template-row';
-import { formatCommunicationsApiError } from '@/lib/logic/communications-api-error';
+import { formatCommunicationsApiError } from '@/lib/logic/api-error';
async function withCommunicationsError(fn: () => Promise): Promise {
try {
diff --git a/src/lib/api/database/index.ts b/src/lib/api/database/index.ts
index 7f0e820b6..1fde14664 100644
--- a/src/lib/api/database/index.ts
+++ b/src/lib/api/database/index.ts
@@ -173,7 +173,8 @@ export const getSchemaDocument = async (schemaName: string, id: string) => {
.get(`/database/schemas/${schemaName}/docs/${id}`)
.then(res => res.data)
.catch(err => {
- if (err.response.status === 404) throw new Error('not_found');
+ if (err.response?.status === 404) throw new Error('not_found');
+ throw err;
});
};
diff --git a/src/lib/logic/EnvManager.ts b/src/lib/logic/EnvManager.ts
index 6448d3387..f74d69060 100644
--- a/src/lib/logic/EnvManager.ts
+++ b/src/lib/logic/EnvManager.ts
@@ -1,6 +1,6 @@
'use server';
-import { isEmpty } from 'lodash';
+import { createHash } from 'crypto';
import { cookies } from 'next/headers';
export interface Environment {
@@ -27,24 +27,76 @@ export interface EnvironmentConfig {
// Global cache for configuration
let configCache: EnvironmentConfig | null = null;
+let configCacheFingerprint: string | null = null;
+
+function hashSecret(value: string | undefined): string {
+ if (!value) return '[unset]';
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
+}
+
+function getConfigFingerprint(): string {
+ const mode = process.env.ENVIRONMENT_MODE || 'single';
+ const envVars = process.env;
+
+ if (mode === 'single') {
+ return JSON.stringify({
+ mode,
+ defaultEnvironment: process.env.DEFAULT_ENVIRONMENT,
+ apiBaseUrl: process.env.API_BASE_URL,
+ masterKeyHash: hashSecret(process.env.MASTER_KEY),
+ });
+ }
+
+ const environments: Record<
+ string,
+ { baseUrl?: string; masterKeyHash: string }
+ > = {};
+ Object.keys(envVars)
+ .filter(key => key.endsWith('_API_BASE_URL'))
+ .sort()
+ .forEach(key => {
+ const prefix = key.replace('_API_BASE_URL', '').toUpperCase();
+ environments[prefix] = {
+ baseUrl: envVars[`${prefix}_API_BASE_URL`],
+ masterKeyHash: hashSecret(envVars[`${prefix}_MASTER_KEY`]),
+ };
+ });
+
+ return JSON.stringify({
+ mode,
+ defaultEnvironment: process.env.DEFAULT_ENVIRONMENT,
+ environments,
+ });
+}
/**
* Get the environment configuration
*/
export async function getConfig(): Promise {
- if (configCache) {
+ const fingerprint = getConfigFingerprint();
+ if (configCache && configCacheFingerprint === fingerprint) {
return configCache;
}
const mode = process.env.ENVIRONMENT_MODE || 'single';
- const defaultEnvironment = process.env.DEFAULT_ENVIRONMENT || 'Local';
+ const defaultEnvironment =
+ process.env.DEFAULT_ENVIRONMENT || (mode === 'single' ? 'Local' : '');
+
+ if (mode === 'multi' && !defaultEnvironment) {
+ throw new Error(
+ 'DEFAULT_ENVIRONMENT must be set in multi-environment mode'
+ );
+ }
if (mode === 'single') {
- configCache = await getSingleEnvironmentConfig(defaultEnvironment);
+ configCache = await getSingleEnvironmentConfig(
+ defaultEnvironment || 'Local'
+ );
} else {
- configCache = await getMultiEnvironmentConfig();
+ configCache = await getMultiEnvironmentConfig(defaultEnvironment);
}
+ configCacheFingerprint = fingerprint;
return configCache;
}
@@ -80,9 +132,10 @@ async function getSingleEnvironmentConfig(
/**
* Get configuration for multi-environment mode
*/
-async function getMultiEnvironmentConfig(): Promise {
+async function getMultiEnvironmentConfig(
+ defaultEnvironment: string
+): Promise {
const environments: Environment[] = [];
- const defaultEnvironment = process.env.DEFAULT_ENVIRONMENT || 'production';
// Get all environment variables
const envVars = process.env;
@@ -164,7 +217,7 @@ export async function getCurrentEnvironment(): Promise {
const activeEnvName = cookie?.value || config.defaultEnvironment;
const environment = config.environments.find(
- env => env.name === activeEnvName
+ env => env.name.toLowerCase() === activeEnvName.toLowerCase()
);
if (!environment) {
throw new Error(`Environment '${activeEnvName}' not found`);
diff --git a/src/lib/logic/api-error.ts b/src/lib/logic/api-error.ts
new file mode 100644
index 000000000..5084b2714
--- /dev/null
+++ b/src/lib/logic/api-error.ts
@@ -0,0 +1,52 @@
+type AxiosLikeError = {
+ response?: {
+ status?: number;
+ data?: { message?: string; error?: string; name?: string };
+ };
+ message?: string;
+};
+
+export function isAxiosLikeError(err: unknown): err is AxiosLikeError {
+ return Boolean(err && typeof err === 'object' && 'response' in err);
+}
+
+export function getAxiosResponseStatus(err: unknown): number | undefined {
+ if (!isAxiosLikeError(err)) return undefined;
+ return err.response?.status;
+}
+
+export function isAxiosNotFoundError(err: unknown): boolean {
+ return getAxiosResponseStatus(err) === 404;
+}
+
+export function isNextNavigationError(err: unknown): boolean {
+ if (!err || typeof err !== 'object' || !('digest' in err)) return false;
+ const digest = (err as { digest?: string }).digest;
+ if (typeof digest !== 'string') return false;
+ return (
+ digest.startsWith('NEXT_REDIRECT') || digest.startsWith('NEXT_NOT_FOUND')
+ );
+}
+
+export function formatAdminApiError(err: unknown): string {
+ if (isAxiosLikeError(err)) {
+ const data = err.response?.data;
+ if (data?.message) return data.message;
+ if (data?.error) return data.error;
+ if (data?.name && err.response?.status) {
+ return `${data.name} (${err.response.status})`;
+ }
+ return err.message ?? 'Request failed';
+ }
+ return err instanceof Error ? err.message : 'Request failed';
+}
+
+export function formatCommunicationsApiError(err: unknown): string {
+ if (isAxiosLikeError(err)) {
+ if (err.response?.status === 404) {
+ return 'Unified templates API not available — upgrade Conduit to a build that includes CommunicationTemplate CRUD';
+ }
+ return formatAdminApiError(err);
+ }
+ return err instanceof Error ? err.message : 'Request failed';
+}
diff --git a/src/lib/logic/communications-api-error.ts b/src/lib/logic/communications-api-error.ts
deleted file mode 100644
index fb8ed646a..000000000
--- a/src/lib/logic/communications-api-error.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export function formatCommunicationsApiError(err: unknown): string {
- if (err && typeof err === 'object' && 'response' in err) {
- const axiosErr = err as {
- response?: { status?: number; data?: { message?: string } };
- message?: string;
- };
- if (axiosErr.response?.status === 404) {
- return 'Unified templates API not available — upgrade Conduit to a build that includes CommunicationTemplate CRUD';
- }
- return (
- axiosErr.response?.data?.message ?? axiosErr.message ?? 'Request failed'
- );
- }
- return err instanceof Error ? err.message : 'Request failed';
-}