feat: show tenancy scope (Org → Chapter) in chapter & member portals

Surface where the logged-in user sits in the hierarchy via a ScopeCard
in the sidebar, below the brand mark.

API:
- Enrich chapter login/me responses with tenantName + organization
  (LoginResponse.admin, AdminProfile types updated)
- Add GET /my/scope returning member's chapter + organization

Web:
- ScopeCard component (Organisation row + Chapter row, themed icons)
- Chapter portal: scope from auth context, blue accent
- Member portal: scope fetched server-side in layout, emerald accent
- PortalShell gains a `scope` slot rendered under the brand

Org and admin portals intentionally omit it — org IS the scope, admin is
platform-wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:27:23 +05:30
parent 205418fc4e
commit a3798b3059
10 changed files with 131 additions and 8 deletions
+2
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/app/_lib/auth-context';
import { PortalShell } from '@/app/_components/portal-shell';
import { ScopeCard } from '@/app/_components/scope-card';
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
const NAV = [
@@ -32,6 +33,7 @@ export default function ChapterLayout({ children }: { children: React.ReactNode
loading={loading}
authed={!!admin}
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
scope={admin ? <ScopeCard accent="text-blue-600" organization={admin.organization} chapter={admin.tenantName ? { name: admin.tenantName } : null} /> : undefined}
onLogout={() => { void logout(); router.replace('/login'); }}
>
{children}
+6 -1
View File
@@ -34,6 +34,8 @@ interface PortalShellProps {
theme: PortalTheme;
navItems: PortalNavItem[];
user?: PortalUser;
/** Optional scope indicator (e.g. <ScopeCard/>) rendered below the brand */
scope?: React.ReactNode;
loading: boolean;
authed: boolean;
onLogout: () => void;
@@ -41,7 +43,7 @@ interface PortalShellProps {
}
export function PortalShell({
portalName, theme, navItems, user, loading, authed, onLogout, children,
portalName, theme, navItems, user, scope, loading, authed, onLogout, children,
}: PortalShellProps) {
const pathname = usePathname();
const t = PORTAL_THEMES[theme];
@@ -61,6 +63,9 @@ export function PortalShell({
</div>
</div>
{/* Scope */}
{!loading && authed && scope}
{/* Nav */}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{loading ? (
+40
View File
@@ -0,0 +1,40 @@
import { Building2, Network } from 'lucide-react';
import { cn } from '../_lib/utils';
interface ScopeCardProps {
organization?: { name: string } | null;
chapter?: { name: string } | null;
/** Tailwind text color for the icons, e.g. text-blue-600 */
accent?: string;
}
/**
* Shows the tenancy scope (Organisation → Chapter) the current user
* is operating within. Rendered in a portal sidebar below the brand.
*/
export function ScopeCard({ organization, chapter, accent = 'text-muted-foreground' }: ScopeCardProps) {
if (!organization && !chapter) return null;
return (
<div className="mx-3 mt-3 rounded-lg border bg-card divide-y">
{organization && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Network className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Organisation</p>
<p className="text-sm font-medium truncate mt-0.5">{organization.name}</p>
</div>
</div>
)}
{chapter && (
<div className="flex items-center gap-2.5 px-3 py-2">
<Building2 className={cn('size-4 shrink-0', accent)} />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground leading-none">Chapter</p>
<p className="text-sm font-medium truncate mt-0.5">{chapter.name}</p>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ export interface AuthAdmin {
tenantId: string;
tenantSlug: string;
tenantName?: string;
organization?: { id: string; slug: string; name: string } | null;
}
interface AuthState {
+19 -2
View File
@@ -1,14 +1,31 @@
import { getMemberToken } from '../_lib/api';
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
import { redirect } from 'next/navigation';
import { MemberNav } from './member-nav';
interface MemberScope {
member: { displayName: string | null };
chapter: { id: string; slug: string; name: string };
organization: { id: string; slug: string; name: string } | null;
}
async function fetchScope(token: string): Promise<MemberScope | null> {
const res = await fetch(`${getApiBaseUrl()}/my/scope`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return null;
return (await res.json()) as MemberScope;
}
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
const token = await getMemberToken();
if (!token) redirect('/member-login');
const scope = await fetchScope(token);
return (
<div className="flex h-screen overflow-hidden">
<MemberNav />
<MemberNav scope={scope} />
<main className="flex-1 overflow-auto bg-muted/30">
<div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
</main>
+12 -1
View File
@@ -4,12 +4,19 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '../_lib/utils';
import { Button } from '../_components/ui/button';
import { ScopeCard } from '../_components/scope-card';
import { PORTAL_THEMES } from '../_components/portal-theme';
import {
LayoutDashboard, Newspaper, Calendar, HeartHandshake, Users2,
BookOpen, Sparkles, Images, Contact, Settings, LogOut,
} from 'lucide-react';
interface MemberScope {
member: { displayName: string | null };
chapter: { id: string; slug: string; name: string };
organization: { id: string; slug: string; name: string } | null;
}
const NAV = [
{ href: '/my', label: 'Dashboard', icon: <LayoutDashboard /> },
{ href: '/my/digest', label: 'Digest', icon: <Newspaper /> },
@@ -24,7 +31,7 @@ const NAV = [
{ href: '/my/settings', label: 'Settings', icon: <Settings /> },
];
export function MemberNav() {
export function MemberNav({ scope }: { scope?: MemberScope | null }) {
const pathname = usePathname();
const t = PORTAL_THEMES.emerald;
@@ -45,6 +52,10 @@ export function MemberNav() {
</div>
</div>
{scope && (
<ScopeCard accent="text-emerald-600" organization={scope.organization} chapter={scope.chapter} />
)}
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV.map((item) => {
const isActive = item.href === '/my' ? pathname === '/my' : pathname.startsWith(item.href);