Compare commits
2 Commits
adb48eb76a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bec73b3f0 | ||
|
|
b241f52f7d |
@@ -3,13 +3,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { RoleGate } from '@/components/auth/RoleGate';
|
import { RoleGate } from '@/components/auth/RoleGate';
|
||||||
import { IT_OPS_ALLOWED_ROLES } from '@/lib/it-ops-roles';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||||
|
import { IT_OPS_ALLOWED_ROLES } from '@/lib/it-ops-roles';
|
||||||
|
|
||||||
type DriftShape = {
|
type DriftShape = {
|
||||||
collected_at?: string;
|
collected_at?: string;
|
||||||
guest_count?: number;
|
guest_count?: number;
|
||||||
duplicate_ips?: Record<string, string[]>;
|
duplicate_ips?: Record<string, string[]>;
|
||||||
|
same_name_duplicate_ip_guests?: Record<string, string[]>;
|
||||||
guest_lan_ips_not_in_declared_sources?: string[];
|
guest_lan_ips_not_in_declared_sources?: string[];
|
||||||
declared_lan11_ips_not_on_live_guests?: string[];
|
declared_lan11_ips_not_on_live_guests?: string[];
|
||||||
vmid_ip_mismatch_live_vs_all_vmids_doc?: Array<{ vmid: string; live_ip: string; all_vmids_doc_ip: string }>;
|
vmid_ip_mismatch_live_vs_all_vmids_doc?: Array<{ vmid: string; live_ip: string; all_vmids_doc_ip: string }>;
|
||||||
@@ -76,6 +77,9 @@ export default function ItOpsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dupCount = drift?.duplicate_ips ? Object.keys(drift.duplicate_ips).length : 0;
|
const dupCount = drift?.duplicate_ips ? Object.keys(drift.duplicate_ips).length : 0;
|
||||||
|
const sameNameDupCount = drift?.same_name_duplicate_ip_guests
|
||||||
|
? Object.keys(drift.same_name_duplicate_ip_guests).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoleGate
|
<RoleGate
|
||||||
@@ -140,7 +144,8 @@ export default function ItOpsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||||
<p>Guests (live): {drift.guest_count ?? '—'}</p>
|
<p>Guests (live): {drift.guest_count ?? '—'}</p>
|
||||||
<p>Duplicate guest IPs: {dupCount}</p>
|
<p>Duplicate guest IPs (different names): {dupCount}</p>
|
||||||
|
<p>Same-name IP clones (informational): {sameNameDupCount}</p>
|
||||||
<p>
|
<p>
|
||||||
LAN guests not in declared sources:{' '}
|
LAN guests not in declared sources:{' '}
|
||||||
{drift.guest_lan_ips_not_in_declared_sources?.length ?? 0}
|
{drift.guest_lan_ips_not_in_declared_sources?.length ?? 0}
|
||||||
@@ -159,7 +164,7 @@ export default function ItOpsPage() {
|
|||||||
{dupCount > 0 && (
|
{dupCount > 0 && (
|
||||||
<Card className="bg-gray-800 border-red-900 md:col-span-2">
|
<Card className="bg-gray-800 border-red-900 md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-red-400">Duplicate IPs (fix on cluster)</CardTitle>
|
<CardTitle className="text-red-400">Duplicate IPs — different guest names</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<pre className="text-xs text-gray-300 overflow-x-auto">
|
<pre className="text-xs text-gray-300 overflow-x-auto">
|
||||||
@@ -169,6 +174,23 @@ export default function ItOpsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sameNameDupCount > 0 && (
|
||||||
|
<Card className="bg-gray-800 border-amber-900/60 md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-amber-200">Same-name guests sharing an IP</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||||
|
<p>
|
||||||
|
Multiple VMIDs use the same IP and identical hostname (e.g. clones). Resolve in Proxmox;
|
||||||
|
export no longer fails CI on this alone.
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs text-gray-300 overflow-x-auto">
|
||||||
|
{JSON.stringify(drift.same_name_duplicate_ip_guests, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{(drift.notes?.length ?? 0) > 0 && (
|
{(drift.notes?.length ?? 0) > 0 && (
|
||||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
89
portal/src/components/auth/RoleGate.tsx
Normal file
89
portal/src/components/auth/RoleGate.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { PortalSignInCard } from '@/components/auth/PortalSignInCard';
|
||||||
|
|
||||||
|
interface RoleGateProps {
|
||||||
|
allowedRoles: readonly string[];
|
||||||
|
callbackUrl: string;
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAllowedRole(sessionRoles: string[] | undefined, allowedRoles: readonly string[]) {
|
||||||
|
const normalizedAllowed = new Set(allowedRoles.map((role) => role.toLowerCase()));
|
||||||
|
return (sessionRoles || []).some((role) => normalizedAllowed.has(role.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGate({
|
||||||
|
allowedRoles,
|
||||||
|
callbackUrl,
|
||||||
|
badge,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: RoleGateProps) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600" />
|
||||||
|
<p className="text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4 py-12">
|
||||||
|
<PortalSignInCard
|
||||||
|
badge={badge}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
callbackUrl={callbackUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAllowedRole(session?.roles, allowedRoles)) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900 px-4 py-12">
|
||||||
|
<div className="w-full max-w-lg rounded-2xl border border-gray-800 bg-gray-900/80 p-8 text-center shadow-xl shadow-black/40">
|
||||||
|
<p className="mb-1 text-sm font-medium uppercase tracking-wide text-orange-400">{badge}</p>
|
||||||
|
<h1 className="mb-3 text-2xl font-bold text-white">Access Restricted</h1>
|
||||||
|
<p className="mb-2 text-gray-400">
|
||||||
|
Your account does not currently include one of the roles required for this workspace.
|
||||||
|
</p>
|
||||||
|
<p className="mb-6 text-sm text-gray-500">
|
||||||
|
Required roles: {allowedRoles.join(', ')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md bg-gray-700 px-4 text-base font-medium text-white transition-colors hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Return Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/help/support"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-600 bg-transparent px-4 text-base font-medium text-white transition-colors hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Contact Support
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,32 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { Menu, X } from 'lucide-react';
|
||||||
LayoutDashboard,
|
|
||||||
Server,
|
|
||||||
Network,
|
|
||||||
Settings,
|
|
||||||
Activity,
|
|
||||||
Users,
|
|
||||||
CreditCard,
|
|
||||||
Shield,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
const navigation = [
|
import { primaryNavigation } from '@/lib/portal-navigation';
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
|
||||||
{ name: 'Resources', href: '/resources', icon: Server },
|
|
||||||
{ name: 'Virtual Machines', href: '/vms', icon: Server },
|
|
||||||
{ name: 'Networking', href: '/network', icon: Network },
|
|
||||||
{ name: 'Monitoring', href: '/dashboards', icon: Activity },
|
|
||||||
{ name: 'Users & Access', href: '/users', icon: Users },
|
|
||||||
{ name: 'Billing', href: '/billing', icon: CreditCard },
|
|
||||||
{ name: 'Security', href: '/security', icon: Shield },
|
|
||||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function MobileNavigation() {
|
export function MobileNavigation() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -47,7 +26,7 @@ export function MobileNavigation() {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="md:hidden fixed inset-0 z-40 bg-gray-900/95 backdrop-blur">
|
<div className="md:hidden fixed inset-0 z-40 bg-gray-900/95 backdrop-blur">
|
||||||
<nav className="flex flex-col h-full p-4 pt-20">
|
<nav className="flex flex-col h-full p-4 pt-20">
|
||||||
{navigation.map((item) => {
|
{primaryNavigation.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||||
|
|
||||||
@@ -56,7 +35,7 @@ export function MobileNavigation() {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className={`flex items-center gap-4 p-4 rounded-lg mb-2 transition-colors ${
|
className={`flex items-center gap-4 p-4 rounded-lg mb-2 no-underline transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-orange-500/20 text-orange-500 border border-orange-500/20'
|
? 'bg-orange-500/20 text-orange-500 border border-orange-500/20'
|
||||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||||
@@ -73,4 +52,3 @@ export function MobileNavigation() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user