diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml
index b6826af..62fa428 100644
--- a/.github/workflows/deploy-dev.yml
+++ b/.github/workflows/deploy-dev.yml
@@ -48,9 +48,9 @@ jobs:
- name: Create Environment Configuration
run: |
echo "${{ env.ENV_FILE }}" > .env
- echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env
- echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env
- echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env
+ echo "VITE_BUILD_DATE=$(date -u +'%Y-%m-%d')" >> .env
+ echo "VITE_BUILD_TIME=$(date -u +'%H:%M:%S')" >> .env
+ echo "VITE_BUILD_TS=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> .env
echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env
echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env
echo "VITE_BUILD_WORKFLOW_RUNNER=GitHub Actions" >> .env
diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
index 1b552f2..22fe9b4 100644
--- a/.github/workflows/deploy-prod.yml
+++ b/.github/workflows/deploy-prod.yml
@@ -48,9 +48,9 @@ jobs:
- name: Create Environment Configuration
run: |
echo "${{ env.ENV_FILE }}" > .env
- echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env
- echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env
- echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env
+ echo "VITE_BUILD_DATE=$(date -u +'%Y-%m-%d')" >> .env
+ echo "VITE_BUILD_TIME=$(date -u +'%H:%M:%S')" >> .env
+ echo "VITE_BUILD_TS=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> .env
echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env
echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env
echo "VITE_BUILD_WORKFLOW_RUNNER=GitHub Actions" >> .env
diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml
index 308d0b2..917b314 100644
--- a/.github/workflows/deploy-qa.yml
+++ b/.github/workflows/deploy-qa.yml
@@ -47,9 +47,9 @@ jobs:
- name: Create Environment Configuration
run: |
echo "${{ env.ENV_FILE }}" > .env
- echo "VITE_BUILD_DATE=$(date +'%Y-%m-%d')" >> .env
- echo "VITE_BUILD_TIME=$(date +'%H:%M:%S%z')" >> .env
- echo "VITE_BUILD_TS=$(date +'%Y-%m-%dT%H:%M:%S%z')" >> .env
+ echo "VITE_BUILD_DATE=$(date -u +'%Y-%m-%d')" >> .env
+ echo "VITE_BUILD_TIME=$(date -u +'%H:%M:%S')" >> .env
+ echo "VITE_BUILD_TS=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> .env
echo "VITE_BUILD_COMMIT_SHA=${{ github.sha }}" >> .env
echo "VITE_BUILD_ENV_CODE=${{ env.AWS_ENV_CODE }}" >> .env
echo "VITE_BUILD_WORKFLOW_RUNNER=GitHub Actions" >> .env
diff --git a/README.md b/README.md
index 2c5b047..72e23f1 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,33 @@ The application is configured using Environment Variables. Because single-page a
After project installation and before running the application locally, create the following `.env` files in the project base directory. Learn more in the official [Vite guide for environment variables and modes](https://vitejs.dev/guide/env-and-mode.html).
+#### Setup
+
+1. **Copy the example configuration file:**
+
+ ```bash
+ cp .env.example .env
+ ```
+
+2. **Update variables for your environment:**
+
+ ```env
+ VITE_BASE_URL_API=https://your-api.example.com
+ VITE_TOAST_AUTO_DISMISS_MILLIS=5000
+ ```
+
+3. **Build information** (typically set by CI/CD pipeline):
+ ```env
+ VITE_BUILD_DATE=2026-02-10
+ VITE_BUILD_TIME=14:30:00
+ VITE_BUILD_TS=2026-02-10T14:30:00Z
+ VITE_BUILD_COMMIT_SHA=abc123def456
+ VITE_BUILD_ENV_CODE=dev
+ VITE_BUILD_WORKFLOW_NAME=Build
+ VITE_BUILD_WORKFLOW_RUN_NUMBER=42
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT=1
+ ```
+
#### `.env.local`
The `.env.local` configuration file provides the configuration values when the application is started on a developer's local machine.
diff --git a/package-lock.json b/package-lock.json
index 5fcb69e..2aa550d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,7 +37,8 @@
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"uuid": "13.0.0",
- "yup": "1.7.1"
+ "yup": "1.7.1",
+ "zod": "4.3.6"
},
"devDependencies": {
"@capacitor/cli": "8.1.0",
@@ -14670,7 +14671,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 112cbd7..14789fa 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,8 @@
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"uuid": "13.0.0",
- "yup": "1.7.1"
+ "yup": "1.7.1",
+ "zod": "4.3.6"
},
"devDependencies": {
"@capacitor/cli": "8.1.0",
diff --git a/src/App.tsx b/src/App.tsx
index b2cb6bc..ca6bbbd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,7 +4,6 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';
import ErrorPage from 'common/components/Error/ErrorPage';
-import ConfigContextProvider from './common/providers/ConfigProvider';
import { queryClient } from 'common/utils/query-client';
import AuthProvider from 'common/providers/AuthProvider';
import AxiosProvider from 'common/providers/AxiosProvider';
@@ -25,21 +24,19 @@ setupIonicReact();
const App = () => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/common/hooks/__tests__/useConfig.test.ts b/src/common/hooks/__tests__/useConfig.test.ts
deleted file mode 100644
index 6014ff3..0000000
--- a/src/common/hooks/__tests__/useConfig.test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { renderHook as renderHookWithoutWrapper } from '@testing-library/react';
-
-import { renderHook, waitFor } from 'test/test-utils';
-
-import { useConfig } from '../useConfig';
-
-describe('useConfig', () => {
- it('should return the context', async () => {
- // ARRANGE
- const { result } = renderHook(() => useConfig());
- await waitFor(() => expect(result.current).not.toBeNull());
-
- // ASSERT
- expect(result.current).toBeDefined();
- expect(result.current.VITE_BUILD_ENV_CODE).toBe('test');
- });
-
- it('should throw error when not within provider', () => {
- // ASSERT
- expect(() => renderHookWithoutWrapper(() => useConfig())).toThrow(/hook must be used within/);
- });
-});
diff --git a/src/common/hooks/useConfig.ts b/src/common/hooks/useConfig.ts
deleted file mode 100644
index 47841f0..0000000
--- a/src/common/hooks/useConfig.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useContext } from 'react';
-
-import { Config, ConfigContext } from 'common/providers/ConfigContext';
-
-/**
- * The `useConfig` hook returns the current `ConfigContext` value.
- * @returns {Config} The current `ConfigContext` value, `Config`.
- */
-export const useConfig = (): Config => {
- const context = useContext(ConfigContext);
- if (!context) {
- throw new Error('useConfig hook must be used within a ConfigContextProvider');
- }
-
- return context;
-};
diff --git a/src/common/providers/ConfigContext.ts b/src/common/providers/ConfigContext.ts
deleted file mode 100644
index 1614fbd..0000000
--- a/src/common/providers/ConfigContext.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { createContext } from 'react';
-
-/**
- * The application configuration. The `value` provided by the `ConfigContext`.
- */
-export interface Config {
- VITE_BASE_URL_API: string;
- VITE_BUILD_DATE: string;
- VITE_BUILD_TIME: string;
- VITE_BUILD_TS: string;
- VITE_BUILD_COMMIT_SHA: string;
- VITE_BUILD_ENV_CODE: string;
- VITE_BUILD_WORKFLOW_RUNNER: string;
- VITE_BUILD_WORKFLOW_NAME: string;
- VITE_BUILD_WORKFLOW_RUN_NUMBER: number;
- VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number;
- VITE_TOAST_AUTO_DISMISS_MILLIS: number;
-}
-
-/**
- * The `ConfigContext` instance.
- */
-export const ConfigContext = createContext(undefined);
diff --git a/src/common/providers/ConfigProvider.tsx b/src/common/providers/ConfigProvider.tsx
deleted file mode 100644
index 39ff435..0000000
--- a/src/common/providers/ConfigProvider.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { PropsWithChildren, useEffect, useState } from 'react';
-import { ObjectSchema, ValidationError, number, object, string } from 'yup';
-import { useTranslation } from 'react-i18next';
-
-import { Config, ConfigContext } from './ConfigContext';
-
-/**
- * The `ConfigContextProvider` React component creates, maintains, and provides
- * access to the `ConfigContext` value.
- * Validates the React application configuration values from `import.meta.env`.
- * Throws an `Error` when the configuration is invalid, preventing application
- * startup.
- * @param {PropsWithChildren} props - Component properties, `PropsWithChildren`.
- * @returns {JSX.Element} JSX
- */
-const ConfigContextProvider = ({ children }: PropsWithChildren) => {
- const { t } = useTranslation();
- const [isReady, setIsReady] = useState(false);
- const [config, setConfig] = useState();
-
- /**
- * The configuration validation schema.
- * @see {@link https://github.com/jquense/yup | Yup}
- */
- const configSchema: ObjectSchema = object({
- VITE_BASE_URL_API: string()
- .url()
- .required(({ path }) => t('validation.required-path', { path })),
- VITE_BUILD_DATE: string().default('1970-01-01'),
- VITE_BUILD_TIME: string().default('00:00:00'),
- VITE_BUILD_TS: string().default('1970-01-01T00:00:00+0000'),
- VITE_BUILD_COMMIT_SHA: string().default('local'),
- VITE_BUILD_ENV_CODE: string().default('local'),
- VITE_BUILD_WORKFLOW_RUNNER: string().default('local'),
- VITE_BUILD_WORKFLOW_NAME: string().default('local'),
- VITE_BUILD_WORKFLOW_RUN_NUMBER: number().default(1),
- VITE_BUILD_WORKFLOW_RUN_ATTEMPT: number().default(-1),
- VITE_TOAST_AUTO_DISMISS_MILLIS: number().default(5000),
- });
-
- useEffect(() => {
- try {
- const validatedConfig = configSchema.validateSync(import.meta.env, {
- abortEarly: false,
- stripUnknown: true,
- });
- setConfig(validatedConfig);
- setIsReady(true);
- } catch (err) {
- if (err instanceof ValidationError) {
- throw new Error(
- `${t('error-configuration-validation')}. ${err.errors.reduce((msg, error) => `${msg} ${error}`)}`,
- );
- }
- if (err instanceof Error) throw new Error(`${t('error-configuration')}. ${err.message}`);
- throw err;
- }
-
- // disabling eslint rule because hook is only called on mount and unmount, and we don't want to add dependencies that would cause it to run more than once
- /* eslint-disable react-hooks/exhaustive-deps */
- }, []);
-
- return {isReady && <>{children}>};
-};
-
-export default ConfigContextProvider;
diff --git a/src/common/providers/__tests__/ConfigProvider.test.tsx b/src/common/providers/__tests__/ConfigProvider.test.tsx
deleted file mode 100644
index c993022..0000000
--- a/src/common/providers/__tests__/ConfigProvider.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-import { render, screen } from 'test/test-utils';
-
-import ConfigContextProvider from '../ConfigProvider';
-
-describe('ConfigProvider', () => {
- it('should render successfully', async () => {
- // ARRANGE
- render(
-
-
- ,
- );
- await screen.findByTestId('provider-config');
-
- // ASSERT
- expect(screen.getByTestId('provider-config')).toBeDefined();
- });
-});
-
-describe.skip('ConfigProvider error', () => {
- const originalEnv = process.env;
-
- beforeAll(() => {
- process.env = { NODE_ENV: 'test', PUBLIC_URL: 'localhost' };
- });
-
- afterAll(() => {
- process.env = originalEnv;
- });
-
- it('should throw configuration validation error', () => {
- // ARRANGE
- function renderContextProvider() {
- render(
-
-
- ,
- );
- }
-
- // ASSERT
- expect(renderContextProvider).toThrow(/is a required field/);
- });
-});
diff --git a/src/common/utils/__tests__/config.test.ts b/src/common/utils/__tests__/config.test.ts
new file mode 100644
index 0000000..227c0d3
--- /dev/null
+++ b/src/common/utils/__tests__/config.test.ts
@@ -0,0 +1,423 @@
+import { describe, expect, it } from 'vitest';
+
+import { configSchema } from '../config';
+
+describe('config', () => {
+ describe('configSchema validation', () => {
+ it('should validate environment object with all valid values', () => {
+ // ARRANGE
+ const validEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(validEnv);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.VITE_BUILD_COMMIT_SHA).toBe('abc123def456');
+ expect(result.data.VITE_BUILD_ENV_CODE).toBe('dev');
+ expect(result.data.VITE_BASE_URL_API).toBe('https://api.example.com');
+ expect(result.data.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe(5000);
+ }
+ });
+
+ it('should fail validation when required environment variables are missing', () => {
+ // ARRANGE
+ const incompleteEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ // Missing other required variables
+ };
+
+ // ACT
+ const result = configSchema.safeParse(incompleteEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_BUILD_DATE is invalid', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: 'invalid-date',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_BUILD_TIME is invalid', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: 'invalid-time',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_BUILD_TS is invalid datetime', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: 'invalid-datetime',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_BASE_URL_API is not a valid URL', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'not-a-valid-url',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should coerce string numbers to integers', () => {
+ // ARRANGE
+ const envWithStringNumbers = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: '99',
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: '3',
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: '3000',
+ };
+
+ // ACT
+ const result = configSchema.safeParse(envWithStringNumbers);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(typeof result.data.VITE_BUILD_WORKFLOW_RUN_NUMBER).toBe('number');
+ expect(result.data.VITE_BUILD_WORKFLOW_RUN_NUMBER).toBe(99);
+ expect(typeof result.data.VITE_BUILD_WORKFLOW_RUN_ATTEMPT).toBe('number');
+ expect(result.data.VITE_BUILD_WORKFLOW_RUN_ATTEMPT).toBe(3);
+ expect(typeof result.data.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe('number');
+ expect(result.data.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe(3000);
+ }
+ });
+
+ it('should use default value for VITE_TOAST_AUTO_DISMISS_MILLIS when not provided', () => {
+ // ARRANGE
+ const envWithoutToastDefault = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ // VITE_TOAST_AUTO_DISMISS_MILLIS is not provided
+ };
+
+ // ACT
+ const result = configSchema.safeParse(envWithoutToastDefault);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe(5000);
+ }
+ });
+
+ it('should fail validation when VITE_BUILD_WORKFLOW_RUN_NUMBER is negative', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: -1,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_BUILD_WORKFLOW_RUN_ATTEMPT is negative', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: -5,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should fail validation when VITE_TOAST_AUTO_DISMISS_MILLIS is negative', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: -1000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ });
+
+ it('should validate all required properties are present in parsed config', () => {
+ // ARRANGE
+ const validEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(validEnv);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toHaveProperty('VITE_BUILD_DATE');
+ expect(result.data).toHaveProperty('VITE_BUILD_TIME');
+ expect(result.data).toHaveProperty('VITE_BUILD_TS');
+ expect(result.data).toHaveProperty('VITE_BUILD_COMMIT_SHA');
+ expect(result.data).toHaveProperty('VITE_BUILD_ENV_CODE');
+ expect(result.data).toHaveProperty('VITE_BUILD_WORKFLOW_NAME');
+ expect(result.data).toHaveProperty('VITE_BUILD_WORKFLOW_RUNNER');
+ expect(result.data).toHaveProperty('VITE_BUILD_WORKFLOW_RUN_NUMBER');
+ expect(result.data).toHaveProperty('VITE_BUILD_WORKFLOW_RUN_ATTEMPT');
+ expect(result.data).toHaveProperty('VITE_BASE_URL_API');
+ expect(result.data).toHaveProperty('VITE_TOAST_AUTO_DISMISS_MILLIS');
+ }
+ });
+
+ it('should provide validation errors for invalid inputs', () => {
+ // ARRANGE
+ const invalidEnv = {
+ VITE_BUILD_DATE: 'invalid',
+ VITE_BUILD_TIME: 'invalid',
+ VITE_BUILD_TS: 'invalid',
+ VITE_BUILD_COMMIT_SHA: '',
+ VITE_BUILD_ENV_CODE: '',
+ VITE_BUILD_WORKFLOW_NAME: '',
+ VITE_BUILD_WORKFLOW_RUNNER: '',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 'not-a-number',
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 'not-a-number',
+ VITE_BASE_URL_API: 'invalid-url',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 'not-a-number',
+ };
+
+ // ACT
+ const result = configSchema.safeParse(invalidEnv);
+
+ // ASSERT
+ expect(result.success).toBe(false);
+ if (!result.success) {
+ expect(result.error.issues.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should accept valid ISO 8601 datetime format with timezone', () => {
+ // ARRANGE
+ const validEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(validEnv);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.VITE_BUILD_TS).toBe('2026-02-11T14:30:00Z');
+ }
+ });
+
+ it('should validate workflow run number and attempt are non-negative integers', () => {
+ // ARRANGE
+ const validEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 0,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 0,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 0,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(validEnv);
+
+ // ASSERT
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.VITE_BUILD_WORKFLOW_RUN_NUMBER).toBe(0);
+ expect(result.data.VITE_BUILD_WORKFLOW_RUN_ATTEMPT).toBe(0);
+ expect(result.data.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe(0);
+ }
+ });
+ });
+
+ describe('Config type', () => {
+ it('should have correct type structure', () => {
+ // ARRANGE
+ const validEnv = {
+ VITE_BUILD_DATE: '2026-02-11',
+ VITE_BUILD_TIME: '14:30:00',
+ VITE_BUILD_TS: '2026-02-11T14:30:00Z',
+ VITE_BUILD_COMMIT_SHA: 'abc123def456',
+ VITE_BUILD_ENV_CODE: 'dev',
+ VITE_BUILD_WORKFLOW_NAME: 'CI/CD Pipeline',
+ VITE_BUILD_WORKFLOW_RUNNER: 'GitHub Actions',
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: 42,
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: 1,
+ VITE_BASE_URL_API: 'https://api.example.com',
+ VITE_TOAST_AUTO_DISMISS_MILLIS: 5000,
+ };
+
+ // ACT
+ const result = configSchema.safeParse(validEnv);
+
+ // ASSERT - Verify that the type matches the expected structure
+ expect(result.success).toBe(true);
+ if (result.success) {
+ const config = result.data;
+ expect(typeof config.VITE_BUILD_DATE).toBe('string');
+ expect(typeof config.VITE_BUILD_TIME).toBe('string');
+ expect(typeof config.VITE_BUILD_TS).toBe('string');
+ expect(typeof config.VITE_BUILD_COMMIT_SHA).toBe('string');
+ expect(typeof config.VITE_BUILD_ENV_CODE).toBe('string');
+ expect(typeof config.VITE_BUILD_WORKFLOW_NAME).toBe('string');
+ expect(typeof config.VITE_BUILD_WORKFLOW_RUNNER).toBe('string');
+ expect(typeof config.VITE_BUILD_WORKFLOW_RUN_NUMBER).toBe('number');
+ expect(typeof config.VITE_BUILD_WORKFLOW_RUN_ATTEMPT).toBe('number');
+ expect(typeof config.VITE_BASE_URL_API).toBe('string');
+ expect(typeof config.VITE_TOAST_AUTO_DISMISS_MILLIS).toBe('number');
+ }
+ });
+ });
+});
diff --git a/src/common/utils/config.ts b/src/common/utils/config.ts
new file mode 100644
index 0000000..37d4907
--- /dev/null
+++ b/src/common/utils/config.ts
@@ -0,0 +1,71 @@
+/**
+ * Application configuration utility using Zod schema validation.
+ * Validates and exports environment variables as a type-safe config object.
+ */
+
+import { z } from 'zod';
+
+/**
+ * Zod schema for environment variables validation.
+ * All environment variables must be prefixed with VITE_ for Vite compatibility.
+ * @see https://vite.dev/guide/env-and-mode
+ */
+export const configSchema = z.object({
+ /** Build configuration */
+ VITE_BUILD_DATE: z.iso.date().describe('The date and time when the application was built'),
+ VITE_BUILD_TIME: z.iso.time().describe('The time when the application was built'),
+ VITE_BUILD_TS: z.iso.datetime().describe('The timestamp when the application was built'),
+ VITE_BUILD_COMMIT_SHA: z.string().describe('The Git commit SHA of the build'),
+ VITE_BUILD_ENV_CODE: z.string().describe('The environment code for the build (e.g., local, dev, qa, prd)'),
+ VITE_BUILD_WORKFLOW_NAME: z.string().describe('The name of the CI/CD workflow that produced the build'),
+ VITE_BUILD_WORKFLOW_RUNNER: z.string().describe('The runner of the CI/CD workflow that produced the build'),
+ VITE_BUILD_WORKFLOW_RUN_NUMBER: z.coerce
+ .number()
+ .int()
+ .nonnegative()
+ .describe('The run number of the CI/CD workflow that produced the build'),
+ VITE_BUILD_WORKFLOW_RUN_ATTEMPT: z.coerce
+ .number()
+ .int()
+ .nonnegative()
+ .describe('The attempt number of the CI/CD workflow run that produced the build'),
+ /** API configuration */
+ VITE_BASE_URL_API: z.url().describe('Base URL for REST API service'),
+ /** Application configuration */
+ VITE_TOAST_AUTO_DISMISS_MILLIS: z.coerce
+ .number()
+ .int()
+ .nonnegative()
+ .default(5000)
+ .describe('Duration in milliseconds before toast notifications auto-dismiss'),
+});
+
+/**
+ * Type inference for the configuration object
+ */
+export type Config = z.infer;
+
+/**
+ * Parse and validate environment variables
+ */
+const parseConfig = (): Config => {
+ try {
+ // Parse environment variables using Zod schema
+ const parsed = configSchema.parse(import.meta.env);
+
+ return parsed;
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const validationIssues = error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
+ console.error(`Environment variable validation failed with the following issues: ${validationIssues}`);
+ throw new Error(`Invalid configuration. Details: ${validationIssues}`);
+ }
+ throw error;
+ }
+};
+
+/**
+ * Validated and type-safe configuration object.
+ * This is the main export that should be used throughout the application.
+ */
+export const config = parseConfig();
diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx
index 87fd8f9..9b01409 100644
--- a/src/pages/Account/AccountPage.tsx
+++ b/src/pages/Account/AccountPage.tsx
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { PropsWithTestId } from 'common/components/types';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import ProgressProvider from 'common/providers/ProgressProvider';
import Header from 'common/components/Header/Header';
import SettingsForm from './components/Settings/SettingsForm';
@@ -29,7 +29,6 @@ import List from 'common/components/List/List';
*/
const AccountPage = ({ testid = 'page-account' }: PropsWithTestId) => {
const [diagnosticsCount, setDiagnosticsCount] = useState(0);
- const config = useConfig();
const router = useIonRouter();
const { t } = useTranslation();
diff --git a/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
index 7276b11..54bd389 100644
--- a/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
+++ b/src/pages/Account/components/Diagnostics/BuildDiagnostics.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { BaseComponentProps } from 'common/components/types';
import List from 'common/components/List/List';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
/**
* The `BuildDiagnostics` component displays application diagnostic information
@@ -16,7 +16,6 @@ import { useConfig } from 'common/hooks/useConfig';
* @returns {JSX.Element} JSX
*/
const BuildDiagnostics = ({ className, testid = 'diagnostics-build' }: BaseComponentProps) => {
- const config = useConfig();
const { t } = useTranslation();
return (
diff --git a/src/pages/Auth/SignIn/api/useSignIn.ts b/src/pages/Auth/SignIn/api/useSignIn.ts
index 7ff8bdd..0132324 100644
--- a/src/pages/Auth/SignIn/api/useSignIn.ts
+++ b/src/pages/Auth/SignIn/api/useSignIn.ts
@@ -7,7 +7,7 @@ import { UserTokens } from 'common/models/auth';
import { QueryKey, StorageKey } from 'common/utils/constants';
import storage from 'common/utils/storage';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
/**
* An API hook which authenticates a `User`.
@@ -16,7 +16,6 @@ import { useConfig } from 'common/hooks/useConfig';
export const useSignIn = () => {
const queryClient = useQueryClient();
const axios = useAxios();
- const config = useConfig();
/**
* Authenticates a user.
diff --git a/src/pages/Users/api/useCreateUser.ts b/src/pages/Users/api/useCreateUser.ts
index e04cb27..9f38167 100644
--- a/src/pages/Users/api/useCreateUser.ts
+++ b/src/pages/Users/api/useCreateUser.ts
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';
@@ -28,7 +28,6 @@ export type CreateUserVariables = {
export const useCreateUser = () => {
const axios = useAxios();
const queryClient = useQueryClient();
- const config = useConfig();
/**
* Create a `User`.
diff --git a/src/pages/Users/api/useDeleteUser.ts b/src/pages/Users/api/useDeleteUser.ts
index 611bd51..4eea828 100644
--- a/src/pages/Users/api/useDeleteUser.ts
+++ b/src/pages/Users/api/useDeleteUser.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import reject from 'lodash/reject';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';
@@ -25,7 +25,6 @@ export type DeleteUserVariables = {
export const useDeleteUser = () => {
const axios = useAxios();
const queryClient = useQueryClient();
- const config = useConfig();
/**
* Delete a `User`.
diff --git a/src/pages/Users/api/useGetUser.ts b/src/pages/Users/api/useGetUser.ts
index 36183e7..24219b8 100644
--- a/src/pages/Users/api/useGetUser.ts
+++ b/src/pages/Users/api/useGetUser.ts
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';
@@ -20,7 +20,6 @@ interface UseGetUserProps {
*/
export const useGetUser = ({ userId }: UseGetUserProps) => {
const axios = useAxios();
- const config = useConfig();
const getUser = async (): Promise => {
const response = await axios.request({
diff --git a/src/pages/Users/api/useGetUsers.ts b/src/pages/Users/api/useGetUsers.ts
index 29c7920..952081c 100644
--- a/src/pages/Users/api/useGetUsers.ts
+++ b/src/pages/Users/api/useGetUsers.ts
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';
@@ -11,7 +11,6 @@ import { QueryKey } from 'common/utils/constants';
*/
export const useGetUsers = () => {
const axios = useAxios();
- const config = useConfig();
const getUsers = async (): Promise => {
const response = await axios.request({
diff --git a/src/pages/Users/api/useUpdateUser.ts b/src/pages/Users/api/useUpdateUser.ts
index 418bd1b..cf05474 100644
--- a/src/pages/Users/api/useUpdateUser.ts
+++ b/src/pages/Users/api/useUpdateUser.ts
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import reject from 'lodash/reject';
import { useAxios } from 'common/hooks/useAxios';
-import { useConfig } from 'common/hooks/useConfig';
+import { config } from 'common/utils/config';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';
@@ -25,7 +25,6 @@ export type UpdateUserVariables = {
export const useUpdateUser = () => {
const axios = useAxios();
const queryClient = useQueryClient();
- const config = useConfig();
/**
* Update a `User`.
diff --git a/src/test/wrappers/WithAllProviders.tsx b/src/test/wrappers/WithAllProviders.tsx
index 6f48497..8bf857f 100644
--- a/src/test/wrappers/WithAllProviders.tsx
+++ b/src/test/wrappers/WithAllProviders.tsx
@@ -3,7 +3,6 @@ import { MemoryRouter } from 'react-router';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from 'test/query-client';
-import ConfigContextProvider from 'common/providers/ConfigProvider';
import ToastProvider from 'common/providers/ToastProvider';
import AxiosProvider from 'common/providers/AxiosProvider';
import AuthProvider from 'common/providers/AuthProvider';
@@ -11,19 +10,17 @@ import ScrollProvider from 'common/providers/ScrollProvider';
const WithAllProviders = ({ children }: PropsWithChildren) => {
return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
);
};