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} */
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,
@@ -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))
}
+56 -44
View File
@@ -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<AdminSessionUser | null>(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 (
<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">
<Link href="/" className="block px-5 py-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
</Link>
<nav className="flex-1 px-3 space-y-0.5">
{navLinks.map((link) => {
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
}`}
>
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
</Link>
)
})}
</nav>
<div className="px-3 py-4">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
{dict.logout}
</button>
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
<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">
<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">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p>
</Link>
<nav className="flex-1 px-3 space-y-0.5">
{navLinks.map((link) => {
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
active ? 'bg-orange-600 text-white dark:bg-orange-500 dark:text-white' : 'text-stone-500 hover:text-blue-900 hover:bg-stone-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-[#162038]'
}`}
>
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
</svg>
{dict.nav[link.key]}
</Link>
)
})}
</nav>
<div className="px-3 py-4">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-stone-500 transition-colors hover:bg-stone-100 hover:text-red-500 dark:text-stone-400 dark:hover:bg-[#162038] dark:hover:text-red-300"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
{dict.logout}
</button>
<div className="mt-4 flex items-center gap-2 border-t border-stone-200/80 pt-4 dark:border-blue-900">
<AdminLanguageSwitcher />
<AdminThemeSwitcher />
</div>
</div>
</div>
</aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
</div>
</aside>
<main className="flex-1 overflow-y-auto transition-colors">{children}</main>
</div>
</AdminSessionProvider>
)
}
+17 -3
View File
@@ -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
? [
+6 -24
View File
@@ -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: [
@@ -218,8 +218,10 @@ function LocalSignInForm({
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 {
@@ -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'
+1 -2
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+7 -22
View File
@@ -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'],
@@ -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 }> = [
{ 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
}
+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
- 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",
Regular → Executable
+27 -2
View File
@@ -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" "$@"
+1
View File
@@ -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"
},