Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

/**
* ImpersonationBanner — fixed bar at the very top of the admin shell
* that appears when the current session is an impersonation.
*
* On mount the component GETs /api/v1/auth/impersonation; if the
* response carries `impersonation: true` it renders a yellow warning
* bar reading "Signed in as <target> on behalf of <actor>. Exit".
* Clicking Exit hits DELETE /api/v1/auth/impersonation and reloads
* the page so the rest of the chrome re-renders against the actor's
* restored session.
*
* The banner deliberately fails closed: if the API is unreachable or
* returns an error, no banner is rendered. The cost of a missed
* impersonation indicator (the operator forgets they're impersonating)
* is annoying but reversible; the cost of a false-positive (banner
* appears on a normal session) is more confusing.
*/
import { useEffect, useState, type ReactElement } from 'react';
import { api } from '@/lib/api-client';

interface WhoamiResponse {
impersonation: boolean;
actor_user_id?: string;
target_user_id?: string;
}

export function ImpersonationBanner(): ReactElement | null {
const [state, setState] = useState<WhoamiResponse | null>(null);
const [exiting, setExiting] = useState(false);

useEffect(() => {
let cancelled = false;
api
.get<WhoamiResponse>('/api/v1/auth/impersonation')
.then((data) => {
if (!cancelled) setState(data);
})
.catch(() => {
// Silent: see file header.
});
return () => {
cancelled = true;
};
}, []);

if (!state || !state.impersonation) return null;

const onExit = async () => {
setExiting(true);
try {
await api.delete('/api/v1/auth/impersonation');
window.location.assign('/');
} catch {
setExiting(false);
}
};

return (
<div
role="alert"
className="impersonation-banner"
data-testid="impersonation-banner"
>
<span>
Signed in as <strong>{state.target_user_id}</strong> on behalf of{' '}
<strong>{state.actor_user_id}</strong>.
</span>
<button type="button" onClick={() => void onExit()} disabled={exiting}>
Exit impersonation
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client';

/**
* PluginSidebarSection — admin sidebar entries contributed by active
* plugins. Issue #228.
*
* On mount the component fetches /api/v1/admin/plugin-pages, then
* renders one sidebar link per declared page under a "Plugins"
* section header. The router target is /plugins/{plugin}/{slug}; the
* plugin frontend host is responsible for what loads there.
*
* Why a separate component (rather than the static NAV_SECTIONS
* array in Sidebar.tsx)? Because the plugin set changes at runtime
* — activating a plugin should surface its admin pages on the next
* navigation without a redeploy. The component lazy-imports the
* page-resolver bridge from the plugin frontend host, so the bundle
* stays slim when no plugin is active.
*/
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState, type ReactElement } from 'react';
import { Plug } from 'lucide-react';

import { api } from '@/lib/api-client';

interface PluginPage {
plugin: string;
slug: string;
label: string;
icon?: string;
capability?: string;
}

interface PluginPagesResponse {
pages: PluginPage[];
}

interface Props {
/** Capabilities the current viewer holds. A page with a declared
* capability is hidden unless the viewer carries it. */
viewerCapabilities?: ReadonlySet<string>;
}

export function PluginSidebarSection({
viewerCapabilities,
}: Props): ReactElement | null {
const pathname = usePathname() ?? '/';
const [pages, setPages] = useState<PluginPage[] | null>(null);

useEffect(() => {
let cancelled = false;
api
.get<PluginPagesResponse>('/api/v1/admin/plugin-pages')
.then((data) => {
if (!cancelled) setPages(data.pages ?? []);
})
.catch(() => {
if (!cancelled) setPages([]);
});
return () => {
cancelled = true;
};
}, []);

if (!pages || pages.length === 0) return null;

const visible = pages.filter((p) => {
if (!p.capability) return true;
if (!viewerCapabilities) return true; // fail-open in dev; sidebar isn't security-critical.
return viewerCapabilities.has(p.capability);
});

if (visible.length === 0) return null;

return (
<div className="sidebar__section">
<div className="sidebar__section-head">Plugins</div>
<ul className="sidebar__nav">
{visible.map((p) => {
const href = `/plugins/${encodeURIComponent(p.plugin)}/${encodeURIComponent(p.slug)}`;
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<li
key={`${p.plugin}/${p.slug}`}
className={
active ? 'sidebar__item sidebar__item--active' : 'sidebar__item'
}
>
<Link href={href}>
<Plug aria-hidden width={16} height={16} />
<span>{p.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}
5 changes: 5 additions & 0 deletions apps/admin/src/app/(authenticated)/_components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
Users,
} from 'lucide-react';
import { GlobalSearch } from '../../../components/GlobalSearch';
import { PluginSidebarSection } from './PluginSidebarSection';

type LucideIcon = ComponentType<SVGProps<SVGSVGElement>>;

Expand Down Expand Up @@ -236,6 +237,10 @@ export function Sidebar(): ReactElement {
</ul>
</div>
))}
{/* Dynamic Plugins section — fetched at runtime from
/api/v1/admin/plugin-pages so activating a plugin lights
up its sidebar entries without a redeploy. Issue #228. */}
{!collapsed && <PluginSidebarSection />}
</nav>

{/* Upgrade card — radial-glow forest-2 surface, emerald CTA. */}
Expand Down
Loading
Loading