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'; -}