fix login and some errors while login
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
Reference in New Issue
Block a user