diff --git a/apps/api/src/modules/org/org.controller.ts b/apps/api/src/modules/org/org.controller.ts index 2140029..4dc0142 100644 --- a/apps/api/src/modules/org/org.controller.ts +++ b/apps/api/src/modules/org/org.controller.ts @@ -32,6 +32,15 @@ export class OrgController { return this.orgService.getTenant(admin.organizationId, id); } + @Post('tenants/:id/admins') + createTenantAdmin( + @CurrentOrgAdmin() admin: OrgAdminJwtPayload, + @Param('id') id: string, + @Body() body: { email: string; password: string; role?: string }, + ) { + return this.orgService.createTenantAdmin(admin.organizationId, id, body.email, body.password, body.role); + } + @Get('rules') getRules(@CurrentOrgAdmin() admin: OrgAdminJwtPayload) { return this.orgService.getRules(admin.organizationId); diff --git a/apps/api/src/modules/org/org.service.ts b/apps/api/src/modules/org/org.service.ts index d3ef881..de244d0 100644 --- a/apps/api/src/modules/org/org.service.ts +++ b/apps/api/src/modules/org/org.service.ts @@ -162,4 +162,26 @@ export class OrgService { if (!rule) throw new NotFoundException('Rule not found'); await this.prisma.orgRule.delete({ where: { id: ruleId } }); } + + async createTenantAdmin( + organizationId: string, + tenantId: string, + email: string, + password: string, + role?: string, + ) { + const tenant = await this.prisma.tenant.findFirst({ where: { id: tenantId, organizationId } }); + if (!tenant) throw new NotFoundException('Chapter not found or does not belong to this org'); + + const existing = await this.prisma.admin.findUnique({ + where: { tenantId_email: { tenantId, email } }, + }); + if (existing) throw new ConflictException('An admin with this email already exists for this chapter'); + + const passwordHash = await bcrypt.hash(password, 10); + return this.prisma.admin.create({ + data: { tenantId, email, passwordHash, ...(role ? { role: role as any } : {}) }, + select: { id: true, email: true, role: true, createdAt: true }, + }); + } } diff --git a/apps/web/app/api/org/tenants/[id]/admins/route.ts b/apps/web/app/api/org/tenants/[id]/admins/route.ts new file mode 100644 index 0000000..7bcac7e --- /dev/null +++ b/apps/web/app/api/org/tenants/[id]/admins/route.ts @@ -0,0 +1,21 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +async function getOrgToken(): Promise { + const { cookies } = await import('next/headers'); + return (await cookies()).get('tower_org_token')?.value; +} + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getOrgToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/org/tenants/${id}/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(body), + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} diff --git a/apps/web/app/org/tenants/[id]/page.tsx b/apps/web/app/org/tenants/[id]/page.tsx index cf64ed2..14510b8 100644 --- a/apps/web/app/org/tenants/[id]/page.tsx +++ b/apps/web/app/org/tenants/[id]/page.tsx @@ -9,6 +9,10 @@ export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: const { orgAdmin, loading } = useOrgAdmin(); const router = useRouter(); const [tenant, setTenant] = useState(null); + const [showAdminForm, setShowAdminForm] = useState(false); + const [adminForm, setAdminForm] = useState({ email: '', password: '', role: 'ADMIN' }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); useEffect(() => { if (loading) return; @@ -19,6 +23,31 @@ export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: .catch(() => {}); }, [orgAdmin, loading, router, id]); + async function handleCreateAdmin(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const res = await fetch(`/api/org/tenants/${id}/admins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(adminForm), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(Array.isArray(err.message) ? err.message.join(', ') : (err.message ?? 'Failed')); + } + setShowAdminForm(false); + setAdminForm({ email: '', password: '', role: 'ADMIN' }); + const updated = await fetch(`/api/org/tenants/${id}`).then((r) => r.ok ? r.json() : null); + if (updated) setTenant(updated); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + if (loading || !orgAdmin) return

Loading...

; if (!tenant) return

Chapter not found.

; @@ -44,9 +73,43 @@ export default function OrgTenantDetailPage({ params }: { params: Promise<{ id:
-
+

Chapter Admins

+
+ + {showAdminForm && ( +
void handleCreateAdmin(e)} className="p-5 border-b flex flex-col gap-3"> +
+
+ + setAdminForm({ ...adminForm, email: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required /> +
+
+ + setAdminForm({ ...adminForm, password: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" required /> +
+
+ + +
+
+ {error &&

{error}

} +
+ + +
+
+ )}