fix login and some errors while login
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-2
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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" "$@"
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user