Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web-dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { api } from '@/lib/api'
import CustomerLayout from '@/layouts/CustomerLayout'
import AdminLayout from '@/layouts/AdminLayout'
import SuperAdminLayout from '@/layouts/SuperAdminLayout'
import { ZonForgeBrand } from '@/components/brand/ZonForgeBrand'
import { APP_ROOT_ROUTE, DEFAULT_AUTHENTICATED_ROUTE, FORBIDDEN_ROUTE, NOT_FOUND_ROUTE, ONBOARDING_ROUTE, SUPER_ADMIN_ROUTE, resolvePostLoginRedirect, resolveProtectedRouteRedirect, resolveRoleHomeRoute } from '@/lib/auth-routing'
import { useAuthStore } from '@/stores/auth.store'

Expand Down Expand Up @@ -188,8 +189,9 @@ function PageLoader() {
return (
<div className="flex h-screen items-center justify-center bg-gray-950">
<div className="flex flex-col items-center gap-4">
<ZonForgeBrand />
<div className="h-10 w-10 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
<span className="text-sm text-gray-500">Loading</span>
<span className="text-sm text-gray-500">Loading ZonForge Sentinel...</span>
</div>
</div>
)
Expand Down
147 changes: 147 additions & 0 deletions apps/web-dashboard/src/components/brand/EnterpriseProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Building2,
ChevronDown,
CircleHelp,
CreditCard,
KeyRound,
LogOut,
ScrollText,
Settings,
UserCircle,
Users,
} from 'lucide-react'

type ProfileLink = {
label: string
to: string
icon: 'profile' | 'organization' | 'team' | 'keys' | 'audit' | 'billing' | 'support'
}

const iconMap = {
profile: UserCircle,
organization: Building2,
team: Users,
keys: KeyRound,
audit: ScrollText,
billing: CreditCard,
support: CircleHelp,
}

export function EnterpriseProfileMenu({
displayName,
role,
links,
onSignOut,
align = 'right',
}: {
displayName: string
role: string
links: ProfileLink[]
onSignOut: () => void
align?: 'left' | 'right'
}) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement | null>(null)
const initials = displayName
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase() || 'ZF'

useEffect(() => {
if (!open) return

function handlePointerDown(event: PointerEvent) {
if (!menuRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}

function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}

document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])

return (
<div className="relative" ref={menuRef}>
<button
type="button"
className="flex min-w-0 items-center gap-3 rounded-2xl border border-slate-700/80 bg-slate-900/70 px-2.5 py-2 text-left transition hover:border-sky-500/50 hover:bg-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br from-sky-500 to-blue-700 text-xs font-bold text-white shadow-lg shadow-sky-950/40">
{initials}
</span>
<span className="hidden min-w-0 sm:block">
<span className="block truncate text-sm font-semibold text-white">{displayName}</span>
<span className="block truncate text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">{role}</span>
</span>
<ChevronDown className={`hidden h-4 w-4 shrink-0 text-slate-500 transition sm:block ${open ? 'rotate-180' : ''}`} />
</button>

{open ? (
<div
role="menu"
className={[
'absolute top-[calc(100%+0.6rem)] z-50 w-72 overflow-hidden rounded-2xl border border-slate-700 bg-slate-950 shadow-2xl shadow-black/50 ring-1 ring-white/5',
align === 'right' ? 'right-0' : 'left-0',
].join(' ')}
>
<div className="border-b border-slate-800 px-4 py-3">
<p className="truncate text-sm font-semibold text-white">{displayName}</p>
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-400">ZonForge Sentinel</p>
<p className="mt-1 text-[9px] font-semibold uppercase tracking-[0.18em] text-slate-500">
AI-Native Cybersecurity Platform
</p>
</div>
<div className="p-2">
{links.map((item) => {
const Icon = iconMap[item.icon]
return (
<Link
key={`${item.label}-${item.to}`}
to={item.to}
role="menuitem"
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-300 transition hover:bg-slate-900 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70"
onClick={() => setOpen(false)}
>
<Icon className="h-4 w-4 text-slate-500" />
<span>{item.label}</span>
</Link>
)
})}
</div>
<div className="border-t border-slate-800 p-2">
<button
type="button"
role="menuitem"
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 hover:text-rose-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/70"
onClick={() => {
setOpen(false)
onSignOut()
}}
>
<LogOut className="h-4 w-4" />
<span>Sign Out</span>
</button>
</div>
</div>
) : null}
</div>
)
}

export type { ProfileLink }
79 changes: 79 additions & 0 deletions apps/web-dashboard/src/components/brand/ZonForgeBrand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { SVGProps } from 'react'

type ZonForgeMarkProps = SVGProps<SVGSVGElement> & {
title?: string
}

export function ZonForgeMark({ title = 'ZonForge', className = '', ...props }: ZonForgeMarkProps) {
return (
<svg
viewBox="0 0 64 64"
role="img"
aria-label={title}
className={className}
fill="none"
{...props}
>
<path
d="M32 5 52 14.5v16.2c0 13.4-8.2 23.4-20 28.3C20.2 54.1 12 44.1 12 30.7V14.5L32 5Z"
stroke="currentColor"
strokeWidth="4"
strokeLinejoin="round"
className="text-slate-300"
/>
<path
d="M20.5 26.5h28L35.7 40.1h14.7L23.7 56l8.9-17.2H17.8l2.7-12.3Z"
fill="url(#zonforge-mark-gradient)"
/>
<defs>
<linearGradient id="zonforge-mark-gradient" x1="16" x2="51" y1="23" y2="52" gradientUnits="userSpaceOnUse">
<stop stopColor="#20C6FF" />
<stop offset="1" stopColor="#1678E8" />
</linearGradient>
</defs>
</svg>
)
}

type ZonForgeWordmarkProps = {
compact?: boolean
className?: string
subtitleClassName?: string
}

export function ZonForgeWordmark({
compact = false,
className = '',
subtitleClassName = '',
}: ZonForgeWordmarkProps) {
return (
<div className={`min-w-0 ${className}`}>
<div className="flex items-baseline gap-1.5 whitespace-nowrap leading-none">
<span className="font-black tracking-[0.04em] text-sky-400">ZonForge</span>
<span className="font-black tracking-[0.02em] text-white">Sentinel</span>
</div>
{!compact ? (
<p className={`mt-1 truncate text-[9px] font-semibold uppercase tracking-[0.24em] text-slate-400 ${subtitleClassName}`}>
AI-Native Cybersecurity Platform
</p>
) : null}
</div>
)
}

export function ZonForgeBrand({
compact = false,
className = '',
}: {
compact?: boolean
className?: string
}) {
return (
<div className={`flex min-w-0 items-center gap-3 ${className}`}>
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl border border-sky-400/30 bg-slate-900 shadow-[0_0_22px_rgba(14,165,233,0.16)]">
<ZonForgeMark className="h-7 w-7 text-slate-300" />
</div>
<ZonForgeWordmark compact={compact} />
</div>
)
}
78 changes: 62 additions & 16 deletions apps/web-dashboard/src/components/customer/CustomerLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Bell, CircleHelp, Search, Settings } from "lucide-react";
import { useAuthStore } from "@/stores/auth.store";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { BillingSubscriptionResponse } from "@/lib/api";
import { EnterpriseProfileMenu, type ProfileLink } from "@/components/brand/EnterpriseProfileMenu";
import { ZonForgeBrand } from "@/components/brand/ZonForgeBrand";
import CustomerSidebar from "./CustomerSidebar";
import { UpgradeBanner } from "@/components/billing/UpgradeBanner";
import { TrialCountdown } from "@/components/billing/TrialCountdown";
import { PlanBadge } from "@/components/billing/PlanBadge";

type StoredAuth = {
state?: {
Expand Down Expand Up @@ -99,6 +100,7 @@ export default function CustomerLayout() {
const location = useLocation();
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const clearAuth = useAuthStore((state) => state.clearAuth);

const { data: subData } = useQuery<BillingSubscriptionResponse>({
queryKey: ['customer', 'subscription', 'layout'],
Expand All @@ -114,6 +116,26 @@ export default function CustomerLayout() {
const role = user?.membership?.role || user?.role || auth?.user?.role || "viewer";
const isSocWorkspace = location.pathname === '/dashboard' || location.pathname.startsWith('/dashboard/')
|| location.pathname.startsWith('/app/customer-investigations');
const profileLinks: ProfileLink[] = [
{ label: "My Profile", to: "/app/customer-settings", icon: "profile" },
{ label: "Organization Settings", to: "/app/customer-settings", icon: "organization" },
{ label: "Team Members", to: "/app/customer-settings/team", icon: "team" },
{ label: "API Keys", to: "/app/customer-settings", icon: "keys" },
{ label: "Audit Logs", to: "/app/reports", icon: "audit" },
{ label: "Billing", to: "/app/billing", icon: "billing" },
{ label: "Support", to: "/app/customer-settings", icon: "support" },
];

async function signOut() {
try {
await api.auth.logout();
} catch {
// Logout must remain available even if the session is already expired.
} finally {
clearAuth();
navigate("/login", { replace: true });
}
}

return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_32%),linear-gradient(180deg,#071122_0%,#08152b_100%)] text-slate-100">
Expand All @@ -131,8 +153,8 @@ export default function CustomerLayout() {

<div className="lg:pl-72">
<header className="sticky top-0 z-30 border-b border-slate-800/80 bg-slate-950/70 backdrop-blur-xl">
<div className={`mx-auto flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8 ${isSocWorkspace ? 'max-w-none' : 'max-w-7xl'}`}>
<div className="flex min-w-0 items-center gap-3">
<div className={`mx-auto flex min-h-16 items-center justify-between gap-3 px-4 py-2 sm:px-6 lg:px-8 ${isSocWorkspace ? 'max-w-none' : 'max-w-7xl'}`}>
<div className="flex min-w-0 items-center gap-3 lg:min-w-[300px]">
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900 text-slate-200 lg:hidden"
Expand All @@ -142,24 +164,48 @@ export default function CustomerLayout() {
<MenuIcon />
</button>

<ZonForgeBrand compact className="hidden xl:flex" />
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">
{isSocWorkspace ? 'Security Operations' : 'Executive Overview'}
<span className="md:hidden">ZonForge Sentinel</span>
<span className="hidden md:inline">{isSocWorkspace ? 'Security Operations' : 'Executive Overview'}</span>
</p>
<h1 className="truncate text-base font-semibold text-white">
{pageTitle(location.pathname)}
</h1>
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-base font-semibold text-white">
{pageTitle(location.pathname)}
</h1>
<span className="hidden rounded-full border border-sky-500/30 bg-sky-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-300 md:inline-flex">
ZonForge Sentinel
</span>
</div>
<p className="hidden truncate text-[11px] text-slate-500 md:block">AI-Native Cybersecurity Platform</p>
</div>
</div>

<div className="hidden items-center gap-3 sm:flex">
<div className="text-right">
<p className="text-sm font-semibold text-white">{displayName}</p>
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">{role}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/15 font-semibold text-blue-200 ring-1 ring-blue-400/30">
{displayName.slice(0, 2).toUpperCase()}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<label className="relative hidden w-full max-w-sm lg:block">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
aria-label="Search ZonForge Sentinel"
className="h-10 w-full rounded-xl border border-slate-700 bg-slate-900/80 pl-9 pr-3 text-sm text-slate-100 outline-none transition placeholder:text-slate-500 focus:border-sky-500 focus:ring-2 focus:ring-sky-500/20"
placeholder="Search alerts, users, assets..."
/>
</label>
<button type="button" className="hidden h-10 w-10 place-items-center rounded-xl border border-slate-700 bg-slate-900/70 text-slate-300 transition hover:border-sky-500/50 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 sm:grid" aria-label="Notifications">
<Bell className="h-4 w-4" />
</button>
<button type="button" className="hidden h-10 w-10 place-items-center rounded-xl border border-slate-700 bg-slate-900/70 text-slate-300 transition hover:border-sky-500/50 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 md:grid" aria-label="Help" onClick={() => navigate("/app/customer-settings")}>
<CircleHelp className="h-4 w-4" />
</button>
<button type="button" className="hidden h-10 w-10 place-items-center rounded-xl border border-slate-700 bg-slate-900/70 text-slate-300 transition hover:border-sky-500/50 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/70 md:grid" aria-label="Settings" onClick={() => navigate("/app/customer-settings")}>
<Settings className="h-4 w-4" />
</button>
<EnterpriseProfileMenu
displayName={displayName}
role={role}
links={profileLinks}
onSignOut={signOut}
/>
</div>
</div>
</header>
Expand Down
Loading
Loading