chore: standardize security and quality foundations
This commit is contained in:
@@ -1,60 +1,136 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { getToken } from '../lib/auth'
|
||||
import { canAccess, featureForPath } from '../lib/features'
|
||||
import { useFeatureUser } from './FeatureGate'
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getToken } from "../lib/auth";
|
||||
import { canAccess, featureForPath } from "../lib/features";
|
||||
import { useFeatureUser } from "./FeatureGate";
|
||||
|
||||
type NavigationItem = {
|
||||
href: string
|
||||
label: string
|
||||
shortLabel: string
|
||||
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
|
||||
adminOnly?: boolean
|
||||
match: (path: string) => boolean
|
||||
}
|
||||
href: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
icon: "dashboard" | "media" | "issues" | "invites" | "settings" | "stats";
|
||||
adminOnly?: boolean;
|
||||
match: (path: string) => boolean;
|
||||
};
|
||||
|
||||
const NAVIGATION: NavigationItem[] = [
|
||||
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
||||
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
|
||||
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
||||
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
|
||||
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
|
||||
]
|
||||
{
|
||||
href: "/new-requests",
|
||||
label: "New Request",
|
||||
shortLabel: "New",
|
||||
icon: "media",
|
||||
match: (path) => path === "/new-requests",
|
||||
},
|
||||
{
|
||||
href: "/",
|
||||
label: "My Requests",
|
||||
shortLabel: "Requests",
|
||||
icon: "dashboard",
|
||||
match: (path) => path === "/" || path.startsWith("/requests/"),
|
||||
},
|
||||
{
|
||||
href: "/insights",
|
||||
label: "My Stats",
|
||||
shortLabel: "Stats",
|
||||
icon: "stats",
|
||||
match: (path) => path === "/insights" || path.startsWith("/insights/"),
|
||||
},
|
||||
{
|
||||
href: "/portal/issues",
|
||||
label: "Issues",
|
||||
shortLabel: "Issues",
|
||||
icon: "issues",
|
||||
match: (path) => path.startsWith("/portal/issues"),
|
||||
},
|
||||
{
|
||||
href: "/profile/invites",
|
||||
label: "Invites",
|
||||
shortLabel: "Invites",
|
||||
icon: "invites",
|
||||
match: (path) => path.startsWith("/profile/invites"),
|
||||
},
|
||||
{
|
||||
href: "/admin",
|
||||
label: "Configuration",
|
||||
shortLabel: "Config",
|
||||
icon: "settings",
|
||||
adminOnly: true,
|
||||
match: (path) => path.startsWith("/admin") || path.startsWith("/users"),
|
||||
},
|
||||
];
|
||||
|
||||
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
|
||||
const HIDDEN_ROUTES = ["/login", "/signup", "/forgot-password", "/reset-password", "/how-it-works"];
|
||||
|
||||
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
|
||||
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
|
||||
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
|
||||
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
|
||||
invites: <><circle cx="9" cy="8" r="3" /><path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
|
||||
}
|
||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
||||
function NavigationIcon({ name }: { name: NavigationItem["icon"] }) {
|
||||
const paths: Record<NavigationItem["icon"], React.ReactNode> = {
|
||||
stats: (
|
||||
<>
|
||||
<path d="M4 20h16M6 16v-5m6 5V4m6 12V8" />
|
||||
</>
|
||||
),
|
||||
dashboard: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</>
|
||||
),
|
||||
media: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="15" rx="2" />
|
||||
<path d="m8 3 2 4m4-4 2 4M3 10h18" />
|
||||
<path d="m10 13 5 3-5 3z" />
|
||||
</>
|
||||
),
|
||||
issues: (
|
||||
<>
|
||||
<path d="M12 3 2.8 19h18.4L12 3Z" />
|
||||
<path d="M12 9v4m0 3h.01" />
|
||||
</>
|
||||
),
|
||||
invites: (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceNavigation() {
|
||||
const pathname = usePathname()
|
||||
const { user, ready } = useFeatureUser()
|
||||
const role = user?.role
|
||||
const pathname = usePathname();
|
||||
const { user, ready } = useFeatureUser();
|
||||
const role = user?.role;
|
||||
|
||||
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
|
||||
const items = NAVIGATION.filter(
|
||||
(item) => (!item.adminOnly || role === "admin") && canAccess(user, featureForPath(item.href)),
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? "is-active" : undefined}>
|
||||
<NavigationIcon name={item.icon} />
|
||||
<span>{item.shortLabel}</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user