From a3798b3059c85e5bf03d26ecbcec70eed87063cb Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 20 Jun 2026 18:27:23 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20show=20tenancy=20scope=20(Org=20?= =?UTF-8?q?=E2=86=92=20Chapter)=20in=20chapter=20&=20member=20portals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/modules/auth/auth.service.ts | 17 +++++++--- apps/api/src/modules/my/my.controller.ts | 5 +++ apps/api/src/modules/my/my.service.ts | 23 +++++++++++++ apps/web/app/(chapter)/layout.tsx | 2 ++ apps/web/app/_components/portal-shell.tsx | 7 +++- apps/web/app/_components/scope-card.tsx | 40 +++++++++++++++++++++++ apps/web/app/_lib/auth-context.tsx | 1 + apps/web/app/my/layout.tsx | 21 ++++++++++-- apps/web/app/my/member-nav.tsx | 13 +++++++- packages/types/src/auth.ts | 10 ++++++ 10 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/_components/scope-card.tsx diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 0c74f3f..5718a39 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -26,10 +26,13 @@ export class AuthService { ) {} async login(req: LoginRequest): Promise { - 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, }, }; } diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index 1c91705..991923c 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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); diff --git a/apps/api/src/modules/my/my.service.ts b/apps/api/src/modules/my/my.service.ts index bb475ca..716ca46 100644 --- a/apps/api/src/modules/my/my.service.ts +++ b/apps/api/src/modules/my/my.service.ts @@ -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'); diff --git a/apps/web/app/(chapter)/layout.tsx b/apps/web/app/(chapter)/layout.tsx index f66f1a6..b550fdb 100644 --- a/apps/web/app/(chapter)/layout.tsx +++ b/apps/web/app/(chapter)/layout.tsx @@ -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 ? : undefined} onLogout={() => { void logout(); router.replace('/login'); }} > {children} diff --git a/apps/web/app/_components/portal-shell.tsx b/apps/web/app/_components/portal-shell.tsx index bff4d8c..5b138a3 100644 --- a/apps/web/app/_components/portal-shell.tsx +++ b/apps/web/app/_components/portal-shell.tsx @@ -34,6 +34,8 @@ interface PortalShellProps { theme: PortalTheme; navItems: PortalNavItem[]; user?: PortalUser; + /** Optional scope indicator (e.g. ) 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({ + {/* Scope */} + {!loading && authed && scope} + {/* Nav */}