diff --git a/.env.example b/.env.example index 7160e9c7..1127f631 100644 --- a/.env.example +++ b/.env.example @@ -10,34 +10,27 @@ # ============================================================================= # --- Database ---------------------------------------------------------------- -# PostgreSQL connection strings. -# POSTGRES_PRISMA_URL is the runtime/pooler connection used by the app. -# POSTGRES_URL_NON_POOLING is the direct connection used by Prisma migrations. -# In local development, both values can point to the same database. POSTGRES_PRISMA_URL=postgresql://username:password@localhost:5432/gem_enterprise POSTGRES_URL_NON_POOLING=postgresql://username:password@localhost:5432/gem_enterprise # --- Authentication ---------------------------------------------------------- -# Must be at least 32 characters. The app refuses to start in production -# if this is missing or set to the default development value. -# Generate with: openssl rand -hex 32 +# Must be at least 32 characters. Generate with: openssl rand -hex 32 JWT_SECRET=replace-this-with-a-random-32-char-secret # --- AI / Governed Chat ------------------------------------------------------ -# Anthropic API key for the GEM Concierge chat widget. -# If absent, the widget falls back to rule-based replies (no build failure). -ANTHROPIC_API_KEY=sk-ant-replace-with-real-key - -# Disclosure text shown to users before their first AI message. -# The SHA-256 hash of this exact string is stored in consent_records. -# Keep this value stable across deployments — changing it invalidates existing -# consent receipts. -NEXT_PUBLIC_AI_DISCLOSURE_TEXT=GEM Concierge is an AI assistant. It does not provide legal, financial, or investment advice. Responses are for informational purposes only. For regulated matters, speak with a qualified advisor. +ANTHROPIC_API_KEY=replace-with-anthropic-api-key +NEXT_PUBLIC_AI_DISCLOSURE_TEXT=GEM Concierge is an AI assistant. It does not replace qualified professional review. # --- Application ------------------------------------------------------------- -NEXT_PUBLIC_APP_URL=https://your-deployment-url.vercel.app +NEXT_PUBLIC_APP_URL=https://gemcybersecurityassist.com NEXT_PUBLIC_APP_NAME=GEM Enterprise +# --- GLOBAL-1 / General Translation ------------------------------------------ +# Server-side translation automation credentials. Never expose these through +# NEXT_PUBLIC_ variables or client-side code. +GT_PROJECT_ID=replace-with-general-translation-project-id +GT_API_KEY=replace-with-general-translation-api-key + # --- Email / SMTP ------------------------------------------------------------ SMTP_HOST=smtp.example.com SMTP_PORT=587 @@ -46,11 +39,8 @@ SMTP_PASS=replace-with-smtp-password EMAIL_FROM=GEM Enterprise # --- Admin Bootstrap --------------------------------------------------------- -# Used once by `npm run db:seed` to create the initial admin account. -# Change ADMIN_INITIAL_PASSWORD immediately after first login. ADMIN_EMAIL=admin@example.com ADMIN_INITIAL_PASSWORD=replace-with-temporary-password # --- Audit Logging ----------------------------------------------------------- -# Set to "true" to persist compliance audit events to the audit_logs table. AUDIT_ENABLED=true diff --git a/.github/workflows/i18n-translate.yml b/.github/workflows/i18n-translate.yml new file mode 100644 index 00000000..1871e584 --- /dev/null +++ b/.github/workflows/i18n-translate.yml @@ -0,0 +1,75 @@ +name: Generate multilingual translations + +on: + workflow_dispatch: + inputs: + base_branch: + description: Branch to translate from + required: true + default: main + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + translate: + name: Translate dictionaries and open pull request + runs-on: ubuntu-latest + env: + GT_API_KEY: ${{ secrets.GT_API_KEY }} + GT_PROJECT_ID: ${{ secrets.GT_PROJECT_ID }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.base_branch }} + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + + - name: Verify translation credentials + run: | + test -n "$GT_API_KEY" || (echo "GT_API_KEY is not configured" && exit 1) + test -n "$GT_PROJECT_ID" || (echo "GT_PROJECT_ID is not configured" && exit 1) + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate translations + run: npx gt@latest translate + + - name: Validate dictionaries + env: + I18N_STRICT: 'true' + run: pnpm exec tsx scripts/validate-i18n.ts + + - name: Open translation pull request + shell: bash + run: | + if git diff --quiet -- src/i18n/dictionaries; then + echo "No translation changes were generated." + exit 0 + fi + + BRANCH="i18n/general-translation-${GITHUB_RUN_ID}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + git add src/i18n/dictionaries + git commit -m "i18n: refresh GLOBAL-1 translations" + git push --set-upstream origin "$BRANCH" + + gh pr create \ + --base "${{ inputs.base_branch }}" \ + --head "$BRANCH" \ + --title "i18n: refresh GLOBAL-1 translations" \ + --body "Automated General Translation update. Review language quality, RTL rendering, and the preview deployment before merging." diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 22264994..e58782f0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,9 @@ -name: Deploy to Vercel Production +name: Validate and deploy GEM Enterprise on: + pull_request: + branches: + - main push: branches: - main @@ -10,8 +13,8 @@ permissions: contents: read jobs: - deploy: - name: Deploy to Vercel + validate: + name: Validate application runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -34,8 +37,47 @@ jobs: POSTGRES_PRISMA_URL: postgresql://ci:ci@localhost/gem POSTGRES_URL_NON_POOLING: postgresql://ci:ci@localhost/gem - - name: Deploy to Vercel Production - run: npx vercel --prod --token $VERCEL_TOKEN --yes + - name: Validate multilingual dictionaries + env: + I18N_STRICT: 'true' + run: pnpm exec tsx scripts/validate-i18n.ts + + - name: Lint + run: pnpm run lint + + - name: Test + run: pnpm run test + + - name: Build + run: pnpm run build + env: + POSTGRES_PRISMA_URL: postgresql://ci:ci@localhost/gem + POSTGRES_URL_NON_POOLING: postgresql://ci:ci@localhost/gem + JWT_SECRET: ci-only-secret-at-least-32-characters-long + NEXT_PUBLIC_APP_URL: http://localhost:3000 + + deploy: + name: Deploy to Vercel production + needs: validate + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Deploy to Vercel production + run: npx vercel --prod --token "$VERCEL_TOKEN" --yes env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} diff --git a/docs/MULTILINGUAL_INTEGRATION.md b/docs/MULTILINGUAL_INTEGRATION.md new file mode 100644 index 00000000..5c705933 --- /dev/null +++ b/docs/MULTILINGUAL_INTEGRATION.md @@ -0,0 +1,127 @@ +# GEM Enterprise GLOBAL-1 Multilingual Integration + +## Purpose + +This subsystem adds multilingual delivery to the existing GEM Enterprise Next.js application without replacing its authentication, KYC, admin, portal, or public route structure. + +The first implementation layer localizes the global website shell and establishes the translation workflow. Page content can be migrated into the same dictionaries incrementally. + +## Supported locales + +- English (`en`) — source language +- Spanish (`es`) +- French (`fr`) +- German (`de`) +- Arabic (`ar`) — right-to-left + +## Runtime flow + +1. The server reads the `gem-locale` cookie. +2. If the cookie is absent, it checks the browser `Accept-Language` header. +3. English is used as the final fallback. +4. The root layout sets the document `lang` and `dir` attributes. +5. `I18nProvider` supplies the active dictionary to client components. +6. The global navigation and footer read translated labels from that dictionary. +7. The language selector posts a validated locale to `/api/locale` and refreshes the current route. + +This approach preserves the existing URLs. It does not insert a locale prefix into protected routes, so authentication redirects and portal links remain stable. + +## Files + +- `src/i18n/config.ts` — locale list, cookie settings, browser matching, RTL direction +- `src/i18n/server.ts` — request locale resolution +- `src/i18n/get-dictionary.ts` — server dictionary loading with English fallback +- `src/i18n/types.ts` — dictionary types +- `src/i18n/dictionaries/*.json` — locale content +- `src/components/I18nProvider.tsx` — client locale context +- `src/components/LanguageSwitcher.tsx` — accessible locale selector +- `src/components/GlobalNavigation.tsx` — localized mobile-first navigation +- `src/components/GlobalFooter.tsx` — localized footer +- `src/app/api/locale/route.ts` — validated locale preference endpoint +- `scripts/validate-i18n.ts` — parity, direction, JSON, and placeholder checks +- `gt.config.json` — General Translation configuration +- `.github/workflows/i18n-translate.yml` — controlled translation pull-request workflow + +## General Translation + +Configure these GitHub Actions secrets: + +- `GT_PROJECT_ID` +- `GT_API_KEY` + +They are server-side credentials and must never use the `NEXT_PUBLIC_` prefix. + +To generate translations locally: + +```bash +npx gt@latest translate --dry-run +npx gt@latest translate +I18N_STRICT=true pnpm exec tsx scripts/validate-i18n.ts +``` + +The source dictionary is `src/i18n/dictionaries/en.json`. General Translation writes the configured target dictionaries. + +## Translation readiness + +Spanish currently contains a translated global-shell seed. French, German, and Arabic include safe scaffolds and English fallback coverage until the General Translation workflow is run and reviewed. + +The production workflow uses strict validation. Missing target keys block the production deployment. This prevents incomplete locale packages from silently reaching the production domain. + +## Adding page content + +Server component example: + +```ts +import { getDictionary } from "@/i18n/get-dictionary"; +import { getRequestLocale } from "@/i18n/server"; + +const locale = await getRequestLocale(); +const dictionary = await getDictionary(locale); +``` + +Client component example: + +```ts +import { useI18n } from "@/components/I18nProvider"; + +const { locale, dictionary } = useI18n(); +``` + +Add new English source keys first. Then run the translation workflow, review the generated pull request, validate the preview deployment, and merge only when all required locales are complete. + +## Protected content + +Do not translate: + +- Environment variable names or values +- API route paths +- Database property names +- Code identifiers +- Template placeholders +- Domain names +- GEM Enterprise +- GEM Cybersecurity & Monitoring Assist +- Alliance Trust Realty + +## Deployment sequence + +1. Update English source strings on a feature branch. +2. Run the `Generate multilingual translations` workflow. +3. Review the generated translation pull request. +4. Confirm strict dictionary validation passes. +5. Confirm lint, tests, and production build pass. +6. Verify the Vercel preview in all supported languages. +7. Verify Arabic direction and mobile menu behavior. +8. Merge only after approval. +9. The main workflow deploys only after validation succeeds. + +## Rollback + +If the multilingual shell causes a regression: + +1. Revert the integration pull request. +2. Keep the source dictionaries and translation branch for diagnosis. +3. Restore the original `Navigation` and `Footer` imports in `src/app/layout.tsx`. +4. Confirm the English production route and protected-route behavior. + +The original navigation and footer components remain in the repository as a rollback reference. diff --git a/gt.config.json b/gt.config.json new file mode 100644 index 00000000..abc956b6 --- /dev/null +++ b/gt.config.json @@ -0,0 +1,9 @@ +{ + "defaultLocale": "en", + "locales": ["es", "fr", "de", "ar"], + "files": { + "json": { + "include": ["src/i18n/dictionaries/[locale].json"] + } + } +} diff --git a/scripts/validate-i18n.ts b/scripts/validate-i18n.ts new file mode 100644 index 00000000..9647eda5 --- /dev/null +++ b/scripts/validate-i18n.ts @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + DEFAULT_LOCALE, + SUPPORTED_LOCALES, + getDirection, + type Locale, +} from "../src/i18n/config"; + +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; + +const DICTIONARY_DIR = path.join(process.cwd(), "src", "i18n", "dictionaries"); +const PLACEHOLDER_PATTERN = /\{\{[^{}]+\}\}|\$\{[^{}]+\}|\{[^{}]+\}|%\d*\$?[sdif]/g; +const strict = process.env.I18N_STRICT === "true"; + +function readDictionary(locale: Locale): JsonObject { + const filePath = path.join(DICTIONARY_DIR, `${locale}.json`); + if (!fs.existsSync(filePath)) { + throw new Error(`Missing dictionary: ${path.relative(process.cwd(), filePath)}`); + } + return JSON.parse(fs.readFileSync(filePath, "utf8")) as JsonObject; +} + +function flatten(value: JsonValue, prefix = ""): Record { + if (Array.isArray(value)) { + return value.reduce>( + (result, item, index) => ({ ...result, ...flatten(item, `${prefix}[${index}]`) }), + {}, + ); + } + + if (value && typeof value === "object") { + return Object.entries(value).reduce>((result, [key, item]) => { + const nextPrefix = prefix ? `${prefix}.${key}` : key; + return { ...result, ...flatten(item, nextPrefix) }; + }, {}); + } + + return { [prefix]: value }; +} + +function placeholders(value: JsonValue): string[] { + if (typeof value !== "string") return []; + return [...(value.match(PLACEHOLDER_PATTERN) ?? [])].sort(); +} + +const source = readDictionary(DEFAULT_LOCALE); +const sourceFlat = flatten(source); +const sourceKeys = Object.keys(sourceFlat).sort(); +const errors: string[] = []; +const warnings: string[] = []; + +for (const locale of SUPPORTED_LOCALES) { + const dictionary = readDictionary(locale); + const flat = flatten(dictionary); + const keys = Object.keys(flat).sort(); + const missing = sourceKeys.filter((key) => !(key in flat)); + const unexpected = keys.filter((key) => !(key in sourceFlat)); + const coverage = Math.round(((sourceKeys.length - missing.length) / sourceKeys.length) * 100); + + console.log(`${locale}: ${coverage}% key coverage (${sourceKeys.length - missing.length}/${sourceKeys.length})`); + + if (missing.length) { + const message = `${locale}: missing keys: ${missing.join(", ")}`; + if (strict) errors.push(message); + else warnings.push(message); + } + + if (unexpected.length) errors.push(`${locale}: unexpected keys: ${unexpected.join(", ")}`); + + for (const key of sourceKeys) { + if (!(key in flat)) continue; + const expected = placeholders(sourceFlat[key]); + const actual = placeholders(flat[key]); + if (JSON.stringify(expected) !== JSON.stringify(actual)) { + errors.push(`${locale}: placeholder mismatch at ${key}`); + } + } + + const meta = dictionary.meta; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + errors.push(`${locale}: missing meta object`); + continue; + } + if (meta.locale !== locale) errors.push(`${locale}: meta.locale must equal ${locale}`); + if (meta.direction !== getDirection(locale)) { + errors.push(`${locale}: meta.direction must equal ${getDirection(locale)}`); + } +} + +for (const warning of warnings) console.warn(`WARNING: ${warning}`); + +if (errors.length) { + console.error("i18n validation failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`i18n validation passed in ${strict ? "strict" : "fallback"} mode.`); diff --git a/src/app/api/locale/route.ts b/src/app/api/locale/route.ts new file mode 100644 index 00000000..a3923a45 --- /dev/null +++ b/src/app/api/locale/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + LOCALE_COOKIE, + LOCALE_COOKIE_MAX_AGE, + SUPPORTED_LOCALES, + isLocale, +} from "@/i18n/config"; + +const localeSchema = z.object({ + locale: z.string().refine(isLocale, "Unsupported locale"), +}); + +export async function GET() { + return NextResponse.json( + { + defaultLocale: "en", + supportedLocales: SUPPORTED_LOCALES, + }, + { + headers: { + "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", + }, + }, + ); +} + +export async function POST(request: Request) { + let payload: unknown; + + try { + payload = await request.json(); + } catch { + return NextResponse.json( + { ok: false, error: { code: "INVALID_JSON", message: "Request body must be valid JSON." } }, + { status: 400 }, + ); + } + + const parsed = localeSchema.safeParse(payload); + + if (!parsed.success) { + return NextResponse.json( + { ok: false, error: { code: "INVALID_LOCALE", message: "The requested locale is not supported." } }, + { status: 400 }, + ); + } + + const response = NextResponse.json({ ok: true, locale: parsed.data.locale }); + + response.cookies.set({ + name: LOCALE_COOKIE, + value: parsed.data.locale, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: LOCALE_COOKIE_MAX_AGE, + }); + + return response; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1f8c78c6..50ef5e8b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,8 +2,12 @@ import type { Metadata, Viewport } from "next"; import { headers } from "next/headers"; import "./globals.css"; import { Providers } from "@/components/Providers"; -import { Navigation } from "@/components/Navigation"; -import { Footer } from "@/components/Footer"; +import { GlobalNavigation } from "@/components/GlobalNavigation"; +import { GlobalFooter } from "@/components/GlobalFooter"; +import { I18nProvider } from "@/components/I18nProvider"; +import { getDictionary } from "@/i18n/get-dictionary"; +import { getDirection } from "@/i18n/config"; +import { getRequestLocale } from "@/i18n/server"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { Analytics } from "@vercel/analytics/next"; @@ -13,17 +17,8 @@ export const metadata: Metadata = { default: "GEM Enterprise | Defend. Protect. Prevail.", template: "%s | GEM Enterprise", }, - description: - "Institutional-grade cybersecurity, financial security, and real estate protection for qualified enterprise clients.", - keywords: [ - "GEM Enterprise", - "cybersecurity", - "enterprise security", - "threat intelligence", - "financial security", - "asset protection", - "SOC", - ], + description: "GEM Enterprise integrated digital protection platform.", + keywords: ["GEM Enterprise", "cybersecurity", "enterprise platform", "threat intelligence"], openGraph: { type: "website", locale: "en_US", @@ -45,27 +40,25 @@ export const viewport: Viewport = { }; export default async function RootLayout({ children }: { children: React.ReactNode }) { - const headersList = await headers(); + const [headersList, locale] = await Promise.all([headers(), getRequestLocale()]); const isPortal = headersList.get("x-is-portal") === "1"; + const dictionary = await getDictionary(locale); return ( - + - - {!isPortal && ( - - Skip to main content - - )} - {!isPortal && } -
- {children} -
- {!isPortal &&