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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 8 additions & 3 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -47,12 +51,13 @@ export default function GlobalError({
>
Try Again
</button>
<a
href="/"
<button
type="button"
onClick={handleGoHome}
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-300 bg-white px-6 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-gray-800"
>
Go Home
</a>
</button>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 0 additions & 2 deletions src/components/navigation/navEnv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}, []);
Expand Down
13 changes: 11 additions & 2 deletions src/lib/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions src/lib/api/auth/cookie-options.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
52 changes: 31 additions & 21 deletions src/lib/api/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
};
Expand All @@ -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';
};
Expand All @@ -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),
});
};
2 changes: 1 addition & 1 deletion src/lib/api/communications/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(fn: () => Promise<T>): Promise<T> {
try {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
};

Expand Down
69 changes: 61 additions & 8 deletions src/lib/logic/EnvManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use server';

import { isEmpty } from 'lodash';
import { createHash } from 'crypto';
import { cookies } from 'next/headers';

export interface Environment {
Expand All @@ -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<EnvironmentConfig> {
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;
}

Expand Down Expand Up @@ -80,9 +132,10 @@ async function getSingleEnvironmentConfig(
/**
* Get configuration for multi-environment mode
*/
async function getMultiEnvironmentConfig(): Promise<EnvironmentConfig> {
async function getMultiEnvironmentConfig(
defaultEnvironment: string
): Promise<EnvironmentConfig> {
const environments: Environment[] = [];
const defaultEnvironment = process.env.DEFAULT_ENVIRONMENT || 'production';

// Get all environment variables
const envVars = process.env;
Expand Down Expand Up @@ -164,7 +217,7 @@ export async function getCurrentEnvironment(): Promise<Environment> {
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`);
Expand Down
Loading
Loading