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');