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
+13 -4
View File
@@ -26,10 +26,13 @@ export class AuthService {
) {}
async login(req: LoginRequest): Promise<LoginResponse> {
let tenant: { id: string; slug: string } | null = null;
let tenant:
| { id: string; slug: string; name: string; organization: { id: string; slug: string; name: string } | null }
| null = null;
const tenantInclude = { organization: { select: { id: true, slug: true, name: true } } } as const;
if (req.tenantSlug) {
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug } });
tenant = await this.prisma.tenant.findUnique({ where: { slug: req.tenantSlug }, include: tenantInclude });
if (!tenant) {
throw new UnauthorizedException('Invalid credentials');
}
@@ -48,7 +51,7 @@ export class AuthService {
'Email is registered in multiple tenants — please specify tenantSlug',
);
}
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId } });
const found = await this.prisma.tenant.findUnique({ where: { id: matches[0].tenantId }, include: tenantInclude });
if (!found) {
throw new UnauthorizedException('Invalid credentials');
}
@@ -93,6 +96,8 @@ export class AuthService {
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: tenant.organization,
},
};
}
@@ -100,7 +105,7 @@ export class AuthService {
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
const admin = await this.prisma.admin.findFirst({
where: { id: adminId, tenantId },
include: { tenant: true },
include: { tenant: { include: { organization: { select: { id: true, slug: true, name: true } } } } },
});
if (!admin) throw new UnauthorizedException('Admin not found');
return {
@@ -110,6 +115,8 @@ export class AuthService {
role: admin.role as AdminRole,
tenantId: admin.tenantId,
tenantSlug: admin.tenant.slug,
tenantName: admin.tenant.name,
organization: admin.tenant.organization,
},
};
}
@@ -192,6 +199,8 @@ export class AuthService {
role: PrismaAdminRole.OWNER,
tenantId: tenant.id,
tenantSlug: tenant.slug,
tenantName: tenant.name,
organization: null,
},
};
}
+5
View File
@@ -105,6 +105,11 @@ export class MyController {
return this.service.getDashboard(member.sub, member.tenantId);
}
@Get('scope')
scope(@CurrentMember() member: MemberJwtPayload) {
return this.service.getScope(member.sub, member.tenantId);
}
@Get('digest')
digests(@CurrentMember() member: MemberJwtPayload) {
return this.service.getDigests(member.tenantId);
+23
View File
@@ -186,6 +186,29 @@ export class MyService {
};
}
async getScope(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({
where: { id: userId, tenantId },
select: {
displayName: true,
tenant: {
select: {
id: true,
slug: true,
name: true,
organization: { select: { id: true, slug: true, name: true } },
},
},
},
});
if (!user) throw new NotFoundException('User not found');
return {
member: { displayName: user.displayName },
chapter: { id: user.tenant.id, slug: user.tenant.slug, name: user.tenant.name },
organization: user.tenant.organization,
};
}
async getDashboard(userId: string, tenantId: string) {
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
if (!user) throw new NotFoundException('User not found');
+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);
+10
View File
@@ -40,6 +40,12 @@ export interface LoginRequest {
password: string;
}
export interface ScopeOrganization {
id: string;
slug: string;
name: string;
}
export interface LoginResponse {
token: string;
admin: {
@@ -48,6 +54,8 @@ export interface LoginResponse {
role: AdminRole;
tenantId: string;
tenantSlug: string;
tenantName: string;
organization: ScopeOrganization | null;
};
}
@@ -66,4 +74,6 @@ export interface AdminProfile {
role: AdminRole;
tenantId: string;
tenantSlug: string;
tenantName: string;
organization: ScopeOrganization | null;
}