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:
@@ -26,10 +26,13 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(req: LoginRequest): Promise<LoginResponse> {
|
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) {
|
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) {
|
if (!tenant) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -48,7 +51,7 @@ export class AuthService {
|
|||||||
'Email is registered in multiple tenants — please specify tenantSlug',
|
'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) {
|
if (!found) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
@@ -93,6 +96,8 @@ export class AuthService {
|
|||||||
role: admin.role as AdminRole,
|
role: admin.role as AdminRole,
|
||||||
tenantId: admin.tenantId,
|
tenantId: admin.tenantId,
|
||||||
tenantSlug: tenant.slug,
|
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'] }> {
|
async me(adminId: string, tenantId: string): Promise<{ admin: LoginResponse['admin'] }> {
|
||||||
const admin = await this.prisma.admin.findFirst({
|
const admin = await this.prisma.admin.findFirst({
|
||||||
where: { id: adminId, tenantId },
|
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');
|
if (!admin) throw new UnauthorizedException('Admin not found');
|
||||||
return {
|
return {
|
||||||
@@ -110,6 +115,8 @@ export class AuthService {
|
|||||||
role: admin.role as AdminRole,
|
role: admin.role as AdminRole,
|
||||||
tenantId: admin.tenantId,
|
tenantId: admin.tenantId,
|
||||||
tenantSlug: admin.tenant.slug,
|
tenantSlug: admin.tenant.slug,
|
||||||
|
tenantName: admin.tenant.name,
|
||||||
|
organization: admin.tenant.organization,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,6 +199,8 @@ export class AuthService {
|
|||||||
role: PrismaAdminRole.OWNER,
|
role: PrismaAdminRole.OWNER,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantSlug: tenant.slug,
|
tenantSlug: tenant.slug,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
organization: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ export class MyController {
|
|||||||
return this.service.getDashboard(member.sub, member.tenantId);
|
return this.service.getDashboard(member.sub, member.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('scope')
|
||||||
|
scope(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getScope(member.sub, member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('digest')
|
@Get('digest')
|
||||||
digests(@CurrentMember() member: MemberJwtPayload) {
|
digests(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getDigests(member.tenantId);
|
return this.service.getDigests(member.tenantId);
|
||||||
|
|||||||
@@ -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) {
|
async getDashboard(userId: string, tenantId: string) {
|
||||||
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||||
if (!user) throw new NotFoundException('User not found');
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
|
|||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/app/_lib/auth-context';
|
import { useAuth } from '@/app/_lib/auth-context';
|
||||||
import { PortalShell } from '@/app/_components/portal-shell';
|
import { PortalShell } from '@/app/_components/portal-shell';
|
||||||
|
import { ScopeCard } from '@/app/_components/scope-card';
|
||||||
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
|
import { Search, Users, Inbox, FileText, SlidersHorizontal, Bot } from 'lucide-react';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -32,6 +33,7 @@ export default function ChapterLayout({ children }: { children: React.ReactNode
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
authed={!!admin}
|
authed={!!admin}
|
||||||
user={admin ? { email: admin.email, name: admin.name, role: admin.role, subtitle: admin.tenantName } : undefined}
|
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'); }}
|
onLogout={() => { void logout(); router.replace('/login'); }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface PortalShellProps {
|
|||||||
theme: PortalTheme;
|
theme: PortalTheme;
|
||||||
navItems: PortalNavItem[];
|
navItems: PortalNavItem[];
|
||||||
user?: PortalUser;
|
user?: PortalUser;
|
||||||
|
/** Optional scope indicator (e.g. <ScopeCard/>) rendered below the brand */
|
||||||
|
scope?: React.ReactNode;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -41,7 +43,7 @@ interface PortalShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PortalShell({
|
export function PortalShell({
|
||||||
portalName, theme, navItems, user, loading, authed, onLogout, children,
|
portalName, theme, navItems, user, scope, loading, authed, onLogout, children,
|
||||||
}: PortalShellProps) {
|
}: PortalShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = PORTAL_THEMES[theme];
|
const t = PORTAL_THEMES[theme];
|
||||||
@@ -61,6 +63,9 @@ export function PortalShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope */}
|
||||||
|
{!loading && authed && scope}
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface AuthAdmin {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
tenantName?: string;
|
tenantName?: string;
|
||||||
|
organization?: { id: string; slug: string; name: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { getMemberToken } from '../_lib/api';
|
import { getMemberToken, getApiBaseUrl } from '../_lib/api';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { MemberNav } from './member-nav';
|
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 }) {
|
export default async function MemberLayout({ children }: { children: React.ReactNode }) {
|
||||||
const token = await getMemberToken();
|
const token = await getMemberToken();
|
||||||
if (!token) redirect('/member-login');
|
if (!token) redirect('/member-login');
|
||||||
|
|
||||||
|
const scope = await fetchScope(token);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
<MemberNav />
|
<MemberNav scope={scope} />
|
||||||
<main className="flex-1 overflow-auto bg-muted/30">
|
<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>
|
<div className="p-6 lg:p-8 max-w-6xl mx-auto w-full">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '../_lib/utils';
|
import { cn } from '../_lib/utils';
|
||||||
import { Button } from '../_components/ui/button';
|
import { Button } from '../_components/ui/button';
|
||||||
|
import { ScopeCard } from '../_components/scope-card';
|
||||||
import { PORTAL_THEMES } from '../_components/portal-theme';
|
import { PORTAL_THEMES } from '../_components/portal-theme';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Newspaper, Calendar, HeartHandshake, Users2,
|
LayoutDashboard, Newspaper, Calendar, HeartHandshake, Users2,
|
||||||
BookOpen, Sparkles, Images, Contact, Settings, LogOut,
|
BookOpen, Sparkles, Images, Contact, Settings, LogOut,
|
||||||
} from 'lucide-react';
|
} 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 = [
|
const NAV = [
|
||||||
{ href: '/my', label: 'Dashboard', icon: <LayoutDashboard /> },
|
{ href: '/my', label: 'Dashboard', icon: <LayoutDashboard /> },
|
||||||
{ href: '/my/digest', label: 'Digest', icon: <Newspaper /> },
|
{ href: '/my/digest', label: 'Digest', icon: <Newspaper /> },
|
||||||
@@ -24,7 +31,7 @@ const NAV = [
|
|||||||
{ href: '/my/settings', label: 'Settings', icon: <Settings /> },
|
{ href: '/my/settings', label: 'Settings', icon: <Settings /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MemberNav() {
|
export function MemberNav({ scope }: { scope?: MemberScope | null }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const t = PORTAL_THEMES.emerald;
|
const t = PORTAL_THEMES.emerald;
|
||||||
|
|
||||||
@@ -45,6 +52,10 @@ export function MemberNav() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||||
{NAV.map((item) => {
|
{NAV.map((item) => {
|
||||||
const isActive = item.href === '/my' ? pathname === '/my' : pathname.startsWith(item.href);
|
const isActive = item.href === '/my' ? pathname === '/my' : pathname.startsWith(item.href);
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface LoginRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScopeOrganization {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string;
|
token: string;
|
||||||
admin: {
|
admin: {
|
||||||
@@ -48,6 +54,8 @@ export interface LoginResponse {
|
|||||||
role: AdminRole;
|
role: AdminRole;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
|
tenantName: string;
|
||||||
|
organization: ScopeOrganization | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +74,6 @@ export interface AdminProfile {
|
|||||||
role: AdminRole;
|
role: AdminRole;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
|
tenantName: string;
|
||||||
|
organization: ScopeOrganization | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user