fix login and some errors while login

This commit is contained in:
root
2026-06-11 11:57:03 -04:00
parent f9b793a05f
commit 919f583677
16 changed files with 438 additions and 182 deletions
+3 -39
View File
@@ -1,52 +1,16 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const securityHeaders = [ const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
{ 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 apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin) const apiUrl = new URL(apiOrigin)
const ADMIN_BASE_PATH = '/admin' 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 // 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 // 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 // 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). // 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 = { const nextConfig = {
basePath: ADMIN_BASE_PATH, basePath: ADMIN_BASE_PATH,
@@ -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)
})
})
@@ -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<AdminRole>(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE'])
const AdminSessionContext = createContext<AdminSessionUser | null>(null)
export function AdminSessionProvider({
admin,
children,
}: {
admin: AdminSessionUser
children: React.ReactNode
}) {
return <AdminSessionContext.Provider value={admin}>{children}</AdminSessionContext.Provider>
}
export function useAdminSession() {
return useContext(AdminSessionContext)
}
export function canAccessAdminMetrics(admin: Pick<AdminSessionUser, 'role' | 'totpEnabled'> | null | undefined) {
return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role))
}
+14 -2
View File
@@ -9,6 +9,8 @@ import {
useAdminI18n, useAdminI18n,
} from '@/components/I18nProvider' } from '@/components/I18nProvider'
import { resolveBrowserAppUrl } from '@/lib/appUrls' import { resolveBrowserAppUrl } from '@/lib/appUrls'
import { ADMIN_API_BASE } from '@/lib/api'
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
function buildUnifiedLoginUrl(nextPath: string) { function buildUnifiedLoginUrl(nextPath: string) {
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard') 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 { dict } = useAdminI18n()
const pathname = usePathname() const pathname = usePathname()
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false 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', credentials: 'include',
}) })
.then((response) => { .then(async (response) => {
if (cancelled) return if (cancelled) return
if (response.ok) { 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) setReady(true)
} else { } else {
window.location.replace(buildUnifiedLoginUrl(pathname)) window.location.replace(buildUnifiedLoginUrl(pathname))
@@ -74,6 +84,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
} }
return ( return (
<AdminSessionProvider admin={admin as AdminSessionUser}>
<div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100"> <div className="flex h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-stone-900 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78"> <aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
<Link href="/" className="block px-5 py-6"> <Link href="/" className="block px-5 py-6">
@@ -117,5 +128,6 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
</aside> </aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main> <main className="flex-1 overflow-y-auto transition-colors">{children}</main>
</div> </div>
</AdminSessionProvider>
) )
} }
+17 -3
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useAdminI18n } from '@/components/I18nProvider' import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api' import { ADMIN_API_BASE } from '@/lib/api'
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
interface Metrics { interface Metrics {
totalCompanies: number totalCompanies: number
@@ -15,6 +16,7 @@ interface Metrics {
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const { language, dict } = useAdminI18n() const { language, dict } = useAdminI18n()
const admin = useAdminSession()
const copy = { const copy = {
en: { en: {
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'], kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
@@ -48,15 +50,27 @@ export default function AdminDashboardPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
if (!canAccessAdminMetrics(admin)) {
setMetrics(null)
setLoading(false)
return
}
fetch(`${ADMIN_API_BASE}/admin/metrics`, { fetch(`${ADMIN_API_BASE}/admin/metrics`, {
credentials: 'include', credentials: 'include',
cache: 'no-store', cache: 'no-store',
}) })
.then((r) => r.json()) .then(async (response) => {
.then((json) => setMetrics(json.data)) const json = await response.json().catch(() => null)
if (!response.ok) {
setMetrics(null)
return
}
setMetrics((json?.data ?? json) as Metrics)
})
.catch(() => null) .catch(() => null)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [admin])
const kpis = metrics const kpis = metrics
? [ ? [
+6 -24
View File
@@ -1,27 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const securityHeaders = [ const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
{ 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 toStoragePattern = (rawUrl) => { const toStoragePattern = (rawUrl) => {
try { try {
const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, '')) const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, ''))
@@ -46,13 +25,16 @@ const storagePatterns = [
.filter(Boolean) .filter(Boolean)
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i) .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 // 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 // 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. // 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 = { const nextConfig = {
basePath: '/dashboard', basePath: DASHBOARD_BASE_PATH,
...(assetPrefix ? { assetPrefix } : {}), ...(assetPrefix ? { assetPrefix } : {}),
images: { images: {
remotePatterns: [ remotePatterns: [
@@ -218,8 +218,10 @@ function LocalSignInForm({
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const requestedPortal = searchParams.get('portal')
const adminNext = searchParams.get('next') || '/dashboard' const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard' const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const preferAdminAuth = requestedPortal === 'admin'
function redirectAdmin(token: string) { function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString() const hash = new URLSearchParams({ token, next: adminNext }).toString()
@@ -232,6 +234,29 @@ function LocalSignInForm({
setError(null) setError(null)
try { try {
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 (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return true
}
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`, { const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -255,31 +280,25 @@ function LocalSignInForm({
// Use a document navigation here so the authenticated dashboard bootstraps // Use a document navigation here so the authenticated dashboard bootstraps
// from a fresh request after the token cookie and localStorage are set. // from a fresh request after the token cookie and localStorage are set.
window.location.replace(targetPath) window.location.replace(targetPath)
return return true
} }
if (empJson?.error === 'password_not_set') { if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet) setError(dict.passwordNotSet)
return true
}
return false
}
if (preferAdminAuth) {
if (await tryAdminLogin()) return
setError(dict.invalidCredentials)
return return
} }
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, { if (await tryEmployeeLogin()) return
method: 'POST', if (await tryAdminLogin()) return
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
}
setError(dict.invalidCredentials) setError(dict.invalidCredentials)
} catch { } catch {
@@ -1,6 +1,5 @@
import * as React from "react";
'use client' 'use client'
import * as React from "react";
import PublicFooter from '@/components/layout/PublicFooter' import PublicFooter from '@/components/layout/PublicFooter'
import PublicHeader from '@/components/layout/PublicHeader' import PublicHeader from '@/components/layout/PublicHeader'
+1 -2
View File
@@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // 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.
+7 -22
View File
@@ -1,27 +1,12 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const securityHeaders = [ const { buildSecurityHeaders } = require('../../config/nextSecurityHeaders')
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'X-Content-Type-Options', value: 'nosniff' }, // The marketplace proxies /dashboard and /admin to their own Next dev servers.
{ key: 'X-Frame-Options', value: 'DENY' }, // Allow those asset-prefix origins so proxied pages can load chunks and HMR.
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, const securityHeaders = buildSecurityHeaders({
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, assetSources: [process.env.DASHBOARD_ASSET_PREFIX, process.env.ADMIN_ASSET_PREFIX],
{ })
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 nextConfig = { const nextConfig = {
images: { images: {
domains: ['res.cloudinary.com'], domains: ['res.cloudinary.com'],
@@ -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')
})
})
@@ -56,6 +56,8 @@ const dictionaries: Record<MarketplaceLanguage, Dictionary> = {
}, },
} }
const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [ export const localeOptions: Array<{ value: MarketplaceLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' }, { value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', 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 { function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language]) const langs = Array.from(navigator.languages ?? [navigator.language])
@@ -201,12 +221,20 @@ export default function MarketplaceShell({
} }
async function syncCompanyBrand() { async function syncCompanyBrand() {
if (!hasCachedEmployeeProfile()) {
setCompanyName(null)
return
}
try { try {
const response = await fetch(`${apiUrl}/companies/me/brand`, { const response = await fetch(`${apiUrl}/companies/me/brand`, {
credentials: 'include', credentials: 'include',
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) {
clearCachedEmployeeProfile()
}
setCompanyName(null) setCompanyName(null)
return return
} }
+107
View File
@@ -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,
}
+30 -1
View File
@@ -81,6 +81,17 @@ services:
- postgres_bootstrap_state:/state - postgres_bootstrap_state:/state
- api_uploads_dev:/var/lib/rentaldrivego/storage - 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: api:
build: build:
context: . context: .
@@ -92,11 +103,15 @@ services:
condition: service_started condition: service_started
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
types:
condition: service_started
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment: environment:
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_INTERVAL: "1000"
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage 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: ports:
- "4000:4000" - "4000:4000"
volumes: volumes:
@@ -112,8 +127,13 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
depends_on: depends_on:
- api - api
- types
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment:
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_INTERVAL: "1000"
WATCHPACK_POLLING: "true"
command: command:
[ [
"sh", "sh",
@@ -135,8 +155,13 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
depends_on: depends_on:
- api - api
- types
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment:
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_INTERVAL: "1000"
WATCHPACK_POLLING: "true"
command: command:
[ [
"sh", "sh",
@@ -158,10 +183,14 @@ services:
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
depends_on: depends_on:
- api - api
- types
env_file: env_file:
- .env.docker.dev - .env.docker.dev
environment: environment:
ADMIN_ASSET_PREFIX: "http://localhost:3002" ADMIN_ASSET_PREFIX: "http://localhost:3002"
CHOKIDAR_USEPOLLING: "true"
CHOKIDAR_INTERVAL: "1000"
WATCHPACK_POLLING: "true"
command: command:
[ [
"sh", "sh",
Regular → Executable
+27 -2
View File
@@ -1,10 +1,35 @@
#!/bin/sh #!/bin/sh
set -eu 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 if [ "${1-}" = "config" ] && [ "${2-}" = "get" ] && [ "${3-}" = "registry" ]; then
exec "$REAL_NPM" --prefix "$PWD" "$@" exec "$REAL_NPM" --prefix "$(find_workspace_root)" config get registry
fi fi
exec "$REAL_NPM" "$@" exec "$REAL_NPM" "$@"
+1
View File
@@ -5,6 +5,7 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"dev": "tsc --watch --preserveWatchOutput --watchFile dynamicprioritypolling --watchDirectory dynamicprioritypolling",
"build": "tsc", "build": "tsc",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },