diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index 6331e40..1245bbb 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -1,52 +1,16 @@ /** @type {import('next').NextConfig} */ -const securityHeaders = [ - { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - { - key: 'Content-Security-Policy', - value: [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "font-src 'self' data:", - "connect-src 'self' https: wss:", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - "object-src 'none'", - ].join('; '), - }, -] +const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders') const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiUrl = new URL(apiOrigin) const ADMIN_BASE_PATH = '/admin' -function ensureAdminAssetPrefix(rawValue) { - if (!rawValue) return undefined - - try { - const url = new URL(rawValue) - const pathname = url.pathname.replace(/\/$/, '') - if (!pathname.endsWith(ADMIN_BASE_PATH)) { - url.pathname = `${pathname}${ADMIN_BASE_PATH}` - } - return url.toString().replace(/\/$/, '') - } catch { - const trimmed = rawValue.replace(/\/$/, '') - return trimmed.endsWith(ADMIN_BASE_PATH) ? trimmed : `${trimmed}${ADMIN_BASE_PATH}` - } -} - // In Docker dev the admin app runs on port 3002 while the marketplace proxy // serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits // absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from // port 3002, bypassing the proxy (which can't upgrade WebSocket connections). -const assetPrefix = ensureAdminAssetPrefix(process.env.ADMIN_ASSET_PREFIX) +const assetPrefix = normalizeAssetPrefix(process.env.ADMIN_ASSET_PREFIX, ADMIN_BASE_PATH) +const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] }) const nextConfig = { basePath: ADMIN_BASE_PATH, diff --git a/apps/admin/src/app/dashboard/AdminSessionContext.test.ts b/apps/admin/src/app/dashboard/AdminSessionContext.test.ts new file mode 100644 index 0000000..6de0e93 --- /dev/null +++ b/apps/admin/src/app/dashboard/AdminSessionContext.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { canAccessAdminMetrics } from './AdminSessionContext' + +describe('canAccessAdminMetrics', () => { + it('allows finance and higher roles after admin 2FA enrollment', () => { + expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true) + expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true) + expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true) + expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true) + }) + + it('denies viewer role and admins who still need 2FA enrollment', () => { + expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false) + expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false) + expect(canAccessAdminMetrics(null)).toBe(false) + }) +}) diff --git a/apps/admin/src/app/dashboard/AdminSessionContext.tsx b/apps/admin/src/app/dashboard/AdminSessionContext.tsx new file mode 100644 index 0000000..31c199b --- /dev/null +++ b/apps/admin/src/app/dashboard/AdminSessionContext.tsx @@ -0,0 +1,34 @@ +'use client' + +import { createContext, useContext } from 'react' + +export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER' + +export type AdminSessionUser = { + id: string + email: string + role: AdminRole + totpEnabled?: boolean +} + +const METRICS_ALLOWED_ROLES = new Set(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE']) + +const AdminSessionContext = createContext(null) + +export function AdminSessionProvider({ + admin, + children, +}: { + admin: AdminSessionUser + children: React.ReactNode +}) { + return {children} +} + +export function useAdminSession() { + return useContext(AdminSessionContext) +} + +export function canAccessAdminMetrics(admin: Pick | null | undefined) { + return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role)) +} diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index 513b5e4..a465fc1 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -9,6 +9,8 @@ import { useAdminI18n, } from '@/components/I18nProvider' import { resolveBrowserAppUrl } from '@/lib/appUrls' +import { ADMIN_API_BASE } from '@/lib/api' +import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext' function buildUnifiedLoginUrl(nextPath: string) { const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard') @@ -36,16 +38,24 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea const { dict } = useAdminI18n() const pathname = usePathname() const [ready, setReady] = useState(false) + const [admin, setAdmin] = useState(null) useEffect(() => { let cancelled = false - fetch(`${process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1'}/admin/auth/me`, { + fetch(`${ADMIN_API_BASE}/admin/auth/me`, { credentials: 'include', }) - .then((response) => { + .then(async (response) => { if (cancelled) return if (response.ok) { + const json = await response.json().catch(() => null) + const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null + if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) { + window.location.replace(buildUnifiedLoginUrl(pathname)) + return + } + setAdmin(resolvedAdmin) setReady(true) } else { window.location.replace(buildUnifiedLoginUrl(pathname)) @@ -74,48 +84,50 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea } return ( -
- -
{children}
-
+ +
{children}
+ + ) } diff --git a/apps/admin/src/app/dashboard/page.tsx b/apps/admin/src/app/dashboard/page.tsx index d13127b..935ef9e 100644 --- a/apps/admin/src/app/dashboard/page.tsx +++ b/apps/admin/src/app/dashboard/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' import { useAdminI18n } from '@/components/I18nProvider' import { ADMIN_API_BASE } from '@/lib/api' +import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext' interface Metrics { totalCompanies: number @@ -15,6 +16,7 @@ interface Metrics { export default function AdminDashboardPage() { const { language, dict } = useAdminI18n() + const admin = useAdminSession() const copy = { en: { kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'], @@ -48,15 +50,27 @@ export default function AdminDashboardPage() { const [loading, setLoading] = useState(true) useEffect(() => { + if (!canAccessAdminMetrics(admin)) { + setMetrics(null) + setLoading(false) + return + } + fetch(`${ADMIN_API_BASE}/admin/metrics`, { credentials: 'include', cache: 'no-store', }) - .then((r) => r.json()) - .then((json) => setMetrics(json.data)) + .then(async (response) => { + const json = await response.json().catch(() => null) + if (!response.ok) { + setMetrics(null) + return + } + setMetrics((json?.data ?? json) as Metrics) + }) .catch(() => null) .finally(() => setLoading(false)) - }, []) + }, [admin]) const kpis = metrics ? [ diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index d150a25..7cec50d 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -1,27 +1,6 @@ /** @type {import('next').NextConfig} */ -const securityHeaders = [ - { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - { - key: 'Content-Security-Policy', - value: [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "font-src 'self' data:", - "connect-src 'self' https: wss:", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - "object-src 'none'", - ].join('; '), - }, -] +const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders') const toStoragePattern = (rawUrl) => { try { const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, '')) @@ -46,13 +25,16 @@ const storagePatterns = [ .filter(Boolean) .filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i) +const DASHBOARD_BASE_PATH = '/dashboard' + // In Docker dev the dashboard app runs on port 3001 while the marketplace proxy // is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit // absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001. -const assetPrefix = process.env.DASHBOARD_ASSET_PREFIX || undefined +const assetPrefix = normalizeAssetPrefix(process.env.DASHBOARD_ASSET_PREFIX, DASHBOARD_BASE_PATH) +const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] }) const nextConfig = { - basePath: '/dashboard', + basePath: DASHBOARD_BASE_PATH, ...(assetPrefix ? { assetPrefix } : {}), images: { remotePatterns: [ diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx index 353387a..587bfb1 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx @@ -218,8 +218,10 @@ function LocalSignInForm({ const [showPassword, setShowPassword] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const requestedPortal = searchParams.get('portal') const adminNext = searchParams.get('next') || '/dashboard' const employeeRedirect = searchParams.get('redirect') || '/dashboard' + const preferAdminAuth = requestedPortal === 'admin' function redirectAdmin(token: string) { const hash = new URLSearchParams({ token, next: adminNext }).toString() @@ -232,54 +234,71 @@ function LocalSignInForm({ setError(null) try { - const empRes = await fetch(`${API_BASE}/auth/employee/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }) - const empJson = await empRes.json() + const tryAdminLogin = async () => { + const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email, password }), + }) + const adminJson = await adminRes.json() - if (empRes.ok && empJson?.data?.employee) { - const targetPath = toPublicDashboardPath(employeeRedirect) - if (empJson?.data?.employee) { - localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee)) - const prefLang = empJson.data.employee?.preferredLanguage - if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') { - setLanguage(prefLang) - document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax` - } + if (adminRes.ok && adminJson?.data?.admin) { + window.location.href = `${adminUrl}/dashboard` + return true } - window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) - notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath }) - // Use a document navigation here so the authenticated dashboard bootstraps - // from a fresh request after the token cookie and localStorage are set. - window.location.replace(targetPath) + + if (adminRes.status === 401 && adminJson?.error === 'totp_required') { + setStep('totp') + return true + } + + return false + } + + const tryEmployeeLogin = async () => { + const empRes = await fetch(`${API_BASE}/auth/employee/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email, password }), + }) + const empJson = await empRes.json() + + if (empRes.ok && empJson?.data?.employee) { + const targetPath = toPublicDashboardPath(employeeRedirect) + if (empJson?.data?.employee) { + localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee)) + const prefLang = empJson.data.employee?.preferredLanguage + if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') { + setLanguage(prefLang) + document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax` + } + } + window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed')) + notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath }) + // Use a document navigation here so the authenticated dashboard bootstraps + // from a fresh request after the token cookie and localStorage are set. + window.location.replace(targetPath) + return true + } + + if (empJson?.error === 'password_not_set') { + setError(dict.passwordNotSet) + return true + } + + return false + } + + if (preferAdminAuth) { + if (await tryAdminLogin()) return + setError(dict.invalidCredentials) return } - if (empJson?.error === 'password_not_set') { - setError(dict.passwordNotSet) - return - } - - const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email, password }), - }) - const adminJson = await adminRes.json() - - if (adminRes.ok && adminJson?.data?.admin) { - window.location.href = `${adminUrl}/dashboard` - return - } - - if (adminRes.status === 401 && adminJson?.error === 'totp_required') { - setStep('totp') - return - } + if (await tryEmployeeLogin()) return + if (await tryAdminLogin()) return setError(dict.invalidCredentials) } catch { diff --git a/apps/dashboard/src/components/layout/PublicShell.tsx b/apps/dashboard/src/components/layout/PublicShell.tsx index 41ab37f..5961258 100644 --- a/apps/dashboard/src/components/layout/PublicShell.tsx +++ b/apps/dashboard/src/components/layout/PublicShell.tsx @@ -1,6 +1,5 @@ -import * as React from "react"; 'use client' - +import * as React from "react"; import PublicFooter from '@/components/layout/PublicFooter' import PublicHeader from '@/components/layout/PublicHeader' diff --git a/apps/marketplace/next-env.d.ts b/apps/marketplace/next-env.d.ts index 9edff1c..4f11a03 100644 --- a/apps/marketplace/next-env.d.ts +++ b/apps/marketplace/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/marketplace/next.config.js b/apps/marketplace/next.config.js index e56b2e2..5fe8706 100644 --- a/apps/marketplace/next.config.js +++ b/apps/marketplace/next.config.js @@ -1,27 +1,12 @@ /** @type {import('next').NextConfig} */ -const securityHeaders = [ - { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, - { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, - { - key: 'Content-Security-Policy', - value: [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob: https:", - "font-src 'self' data:", - "connect-src 'self' https: wss:", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - "object-src 'none'", - ].join('; '), - }, -] +const { buildSecurityHeaders } = require('../../config/nextSecurityHeaders') + +// The marketplace proxies /dashboard and /admin to their own Next dev servers. +// Allow those asset-prefix origins so proxied pages can load chunks and HMR. +const securityHeaders = buildSecurityHeaders({ + assetSources: [process.env.DASHBOARD_ASSET_PREFIX, process.env.ADMIN_ASSET_PREFIX], +}) const nextConfig = { images: { domains: ['res.cloudinary.com'], diff --git a/apps/marketplace/src/components/MarketplaceShell.auth.test.ts b/apps/marketplace/src/components/MarketplaceShell.auth.test.ts new file mode 100644 index 0000000..6ff9253 --- /dev/null +++ b/apps/marketplace/src/components/MarketplaceShell.auth.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clearCachedEmployeeProfile, hasCachedEmployeeProfile } from './MarketplaceShell' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('MarketplaceShell auth helpers', () => { + it('does not report an employee profile when the browser cache is empty', () => { + vi.stubGlobal('window', { + localStorage: { + getItem: vi.fn(() => null), + }, + }) + + expect(hasCachedEmployeeProfile()).toBe(false) + }) + + it('detects the cached employee profile marker written by dashboard sign-in', () => { + vi.stubGlobal('window', { + localStorage: { + getItem: vi.fn((key: string) => (key === 'employee_profile' ? '{"id":"emp_1"}' : null)), + }, + }) + + expect(hasCachedEmployeeProfile()).toBe(true) + }) + + it('clears the cached employee profile marker', () => { + const removeItem = vi.fn() + vi.stubGlobal('window', { + localStorage: { + removeItem, + }, + }) + + clearCachedEmployeeProfile() + + expect(removeItem).toHaveBeenCalledWith('employee_profile') + }) +}) diff --git a/apps/marketplace/src/components/MarketplaceShell.tsx b/apps/marketplace/src/components/MarketplaceShell.tsx index 757e7f6..ce66e84 100644 --- a/apps/marketplace/src/components/MarketplaceShell.tsx +++ b/apps/marketplace/src/components/MarketplaceShell.tsx @@ -56,6 +56,8 @@ const dictionaries: Record = { }, } +const EMPLOYEE_PROFILE_KEY = 'employee_profile' + export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [ { value: 'en', label: 'Global (English)', flag: 'πŸ‡ΊπŸ‡Έ' }, { value: 'ar', label: 'North Africa (Arabic)', flag: 'πŸ‡²πŸ‡¦' }, @@ -127,6 +129,24 @@ export function getFooterContent(language: MarketplaceLanguage): { } } +export function hasCachedEmployeeProfile(): boolean { + if (typeof window === 'undefined') return false + + try { + return Boolean(window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)) + } catch { + return false + } +} + +export function clearCachedEmployeeProfile() { + if (typeof window === 'undefined') return + + try { + window.localStorage.removeItem(EMPLOYEE_PROFILE_KEY) + } catch {} +} + function detectBrowserLanguage(): string | null { if (typeof navigator === 'undefined') return null const langs = Array.from(navigator.languages ?? [navigator.language]) @@ -201,12 +221,20 @@ export default function MarketplaceShell({ } async function syncCompanyBrand() { + if (!hasCachedEmployeeProfile()) { + setCompanyName(null) + return + } + try { const response = await fetch(`${apiUrl}/companies/me/brand`, { credentials: 'include', }) if (!response.ok) { + if (response.status === 401) { + clearCachedEmployeeProfile() + } setCompanyName(null) return } diff --git a/config/nextSecurityHeaders.js b/config/nextSecurityHeaders.js new file mode 100644 index 0000000..d584240 --- /dev/null +++ b/config/nextSecurityHeaders.js @@ -0,0 +1,107 @@ +function normalizeAssetPrefix(rawValue, basePath) { + if (!rawValue) return undefined + + try { + const url = new URL(rawValue) + const pathname = url.pathname.replace(/\/$/, '') + if (!pathname.endsWith(basePath)) { + url.pathname = `${pathname}${basePath}` + } + return url.toString().replace(/\/$/, '') + } catch { + const trimmed = rawValue.replace(/\/$/, '') + return trimmed.endsWith(basePath) ? trimmed : `${trimmed}${basePath}` + } +} + +function collectOrigins(assetSources) { + const origins = new Set() + + for (const source of assetSources) { + if (!source) continue + + try { + origins.add(new URL(source).origin) + } catch { + continue + } + } + + return [...origins] +} + +function collectWebSocketOrigins(assetSources) { + const origins = new Set() + + for (const source of assetSources) { + if (!source) continue + + try { + const url = new URL(source) + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + origins.add(`${protocol}//${url.host}`) + } catch { + continue + } + } + + return [...origins] +} + +function collectBrowserApiSources() { + const sources = [process.env.NEXT_PUBLIC_API_URL, process.env.API_URL] + + if (process.env.NODE_ENV !== 'production') { + sources.push('http://localhost:4000/api/v1') + } + + return sources +} + +function buildSecurityHeaders({ assetSources = [], connectSources = collectBrowserApiSources() } = {}) { + const assetOrigins = collectOrigins(assetSources) + const connectOrigins = collectOrigins(connectSources) + const websocketOrigins = [ + ...collectWebSocketOrigins(assetSources), + ...collectWebSocketOrigins(connectSources), + ] + + const scriptSrc = ["'self'", "'unsafe-inline'"] + if (process.env.NODE_ENV !== 'production') { + scriptSrc.push("'unsafe-eval'") + } + scriptSrc.push(...assetOrigins) + + const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins] + const imgSrc = ["'self'", 'data:', 'blob:', 'https:', ...assetOrigins] + const fontSrc = ["'self'", 'data:', ...assetOrigins] + const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])] + + return [ + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + `script-src ${scriptSrc.join(' ')}`, + `style-src ${styleSrc.join(' ')}`, + `img-src ${imgSrc.join(' ')}`, + `font-src ${fontSrc.join(' ')}`, + `connect-src ${connectSrc.join(' ')}`, + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", + ].join('; '), + }, + ] +} + +module.exports = { + buildSecurityHeaders, + normalizeAssetPrefix, +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6c3747d..5b4723b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -81,6 +81,17 @@ services: - postgres_bootstrap_state:/state - api_uploads_dev:/var/lib/rentaldrivego/storage + types: + build: + context: . + dockerfile: Dockerfile.dev + command: ["sh", "-c", "cd /app/packages/types && exec npm run dev"] + volumes: + - .:/app + - app_node_modules:/app/node_modules + restart: unless-stopped + profiles: ["api", "marketplace", "dashboard", "admin", "full"] + api: build: context: . @@ -92,11 +103,15 @@ services: condition: service_started migrate: condition: service_completed_successfully + types: + condition: service_started env_file: - .env.docker.dev environment: + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "1000" FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage - command: ["sh", "-c", "npm run build --workspace @rentaldrivego/types && cd /app/apps/api && exec /app/node_modules/.bin/ts-node-dev --respawn --transpile-only --ignore-watch ../../packages/types/dist src/index.ts"] + command: ["sh", "-c", "npm run build --workspace @rentaldrivego/types && cd /app/apps/api && exec /app/node_modules/.bin/ts-node-dev --respawn --transpile-only src/index.ts"] ports: - "4000:4000" volumes: @@ -112,8 +127,13 @@ services: dockerfile: Dockerfile.dev depends_on: - api + - types env_file: - .env.docker.dev + environment: + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "1000" + WATCHPACK_POLLING: "true" command: [ "sh", @@ -135,8 +155,13 @@ services: dockerfile: Dockerfile.dev depends_on: - api + - types env_file: - .env.docker.dev + environment: + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "1000" + WATCHPACK_POLLING: "true" command: [ "sh", @@ -158,10 +183,14 @@ services: dockerfile: Dockerfile.dev depends_on: - api + - types env_file: - .env.docker.dev environment: ADMIN_ASSET_PREFIX: "http://localhost:3002" + CHOKIDAR_USEPOLLING: "true" + CHOKIDAR_INTERVAL: "1000" + WATCHPACK_POLLING: "true" command: [ "sh", diff --git a/docker/scripts/npm b/docker/scripts/npm old mode 100644 new mode 100755 index ba741ae..d26a4d8 --- a/docker/scripts/npm +++ b/docker/scripts/npm @@ -1,10 +1,35 @@ #!/bin/sh set -eu -REAL_NPM="/usr/local/bin/npm" +SELF_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) + +if [ -n "${REAL_NPM:-}" ]; then + : +elif [ -x /usr/local/bin/npm ]; then + REAL_NPM=/usr/local/bin/npm +elif [ -x /opt/homebrew/bin/npm ]; then + REAL_NPM=/opt/homebrew/bin/npm +else + PATH_WITHOUT_SELF=$(printf '%s' "$PATH" | sed "s#${SELF_DIR}:##; s#:${SELF_DIR}##; s#${SELF_DIR}\$##") + REAL_NPM=$(PATH="$PATH_WITHOUT_SELF" command -v npm) +fi + +find_workspace_root() { + dir="$PWD" + + while [ "$dir" != "/" ]; do + if [ -f "$dir/package.json" ] && grep -q '"workspaces"' "$dir/package.json"; then + printf '%s\n' "$dir" + return 0 + fi + dir=$(dirname "$dir") + done + + printf '%s\n' "$PWD" +} if [ "${1-}" = "config" ] && [ "${2-}" = "get" ] && [ "${3-}" = "registry" ]; then - exec "$REAL_NPM" --prefix "$PWD" "$@" + exec "$REAL_NPM" --prefix "$(find_workspace_root)" config get registry fi exec "$REAL_NPM" "$@" diff --git a/packages/types/package.json b/packages/types/package.json index 44295b0..9959ee6 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -5,6 +5,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { + "dev": "tsc --watch --preserveWatchOutput --watchFile dynamicprioritypolling --watchDirectory dynamicprioritypolling", "build": "tsc", "type-check": "tsc --noEmit" },