diff --git a/apps/web/app/_lib/org-admin-context.tsx b/apps/web/app/_lib/org-admin-context.tsx new file mode 100644 index 0000000..7bbd6cc --- /dev/null +++ b/apps/web/app/_lib/org-admin-context.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +interface OrgProfile { + id: string; + email: string; + name: string | null; + role: 'ORG_OWNER' | 'ORG_ADMIN'; + organization: { id: string; slug: string; name: string }; +} + +interface OrgAdminState { + orgAdmin: OrgProfile | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const OrgAdminContext = createContext(null); + +export function OrgAdminProvider({ children }: { children: React.ReactNode }) { + const [orgAdmin, setOrgAdmin] = useState(null); + const [loading, setLoading] = useState(true); + + const checkSession = useCallback(async () => { + try { + const res = await fetch('/api/auth/org/me', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setOrgAdmin(data); + } else { + setOrgAdmin(null); + } + } catch { + setOrgAdmin(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void checkSession(); }, [checkSession]); + + const login = useCallback(async (email: string, password: string) => { + const res = await fetch('/api/auth/org/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: 'Login failed' })); + throw new Error(err.message ?? 'Login failed'); + } + const data = await res.json(); + setOrgAdmin(data.orgAdmin); + // Refresh full profile (includes organization object) + void checkSession(); + }, [checkSession]); + + const logout = useCallback(async () => { + await fetch('/api/auth/org/logout', { method: 'POST', credentials: 'include' }); + setOrgAdmin(null); + }, []); + + return ( + + {children} + + ); +} + +export function useOrgAdmin(): OrgAdminState { + const ctx = useContext(OrgAdminContext); + if (!ctx) throw new Error('useOrgAdmin must be used within '); + return ctx; +} diff --git a/apps/web/app/admin/orgs/[id]/page.tsx b/apps/web/app/admin/orgs/[id]/page.tsx new file mode 100644 index 0000000..3847e24 --- /dev/null +++ b/apps/web/app/admin/orgs/[id]/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSuperAdmin } from '../../../_lib/super-admin-context'; + +export default function AdminOrgDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const { admin, loading } = useSuperAdmin(); + const router = useRouter(); + const [org, setOrg] = useState(null); + const [showAdminForm, setShowAdminForm] = useState(false); + const [adminForm, setAdminForm] = useState({ email: '', password: '', name: '', role: 'ORG_OWNER' }); + const [tenantId, setTenantId] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + async function load() { + const res = await fetch(`/api/admin/orgs/${id}`); + if (res.ok) setOrg(await res.json()); + } + + useEffect(() => { + if (loading) return; + if (!admin) { router.replace('/admin/login'); return; } + void load(); + }, [admin, loading, router, id]); + + async function handleCreateAdmin(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const res = await fetch(`/api/admin/orgs/${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(err.message ?? 'Failed'); + } + setShowAdminForm(false); + setAdminForm({ email: '', password: '', name: '', role: 'ORG_OWNER' }); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + + async function handleAssignTenant(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + try { + const res = await fetch(`/api/admin/tenants/${tenantId}/org`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: id }), + }); + if (!res.ok) throw new Error('Failed to assign'); + setTenantId(''); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + + if (loading || !admin) return

Loading...

; + if (!org) return

Organization not found.

; + + return ( +
+ +

{org.name}

+

{org.slug}

+ + {/* Chapters */} +
+
+

Chapters

+
void handleAssignTenant(e)} className="flex gap-2"> + setTenantId(e.target.value)} + className="border rounded-lg px-3 py-1.5 text-sm" + placeholder="Tenant ID to assign" + /> + +
+
+ + + + + + + + + + {(org.tenants ?? []).map((t: any) => ( + + + + + + ))} + +
NameSlugStatus
{t.name}{t.slug} + + {t.isActive ? 'Active' : 'Inactive'} + +
+ {org.tenants?.length === 0 &&

No chapters assigned.

} +
+ + {/* Org Admins */} +
+
+

Org 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 /> +
+
+ + setAdminForm({ ...adminForm, name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" /> +
+
+ + +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + + + + + + + + + + + + {(org.orgAdmins ?? []).map((a: any) => ( + + + + + + + ))} + +
EmailNameRoleCreated
{a.email}{a.name ?? '—'} + {a.role} + {new Date(a.createdAt).toLocaleDateString()}
+ {org.orgAdmins?.length === 0 &&

No org admins yet.

} +
+
+ ); +} diff --git a/apps/web/app/admin/orgs/page.tsx b/apps/web/app/admin/orgs/page.tsx new file mode 100644 index 0000000..b4edceb --- /dev/null +++ b/apps/web/app/admin/orgs/page.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSuperAdmin } from '../../_lib/super-admin-context'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; + +export default function AdminOrgsPage() { + const { admin, loading } = useSuperAdmin(); + const router = useRouter(); + const [orgs, setOrgs] = useState([]); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ slug: '', name: '' }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + async function load() { + const res = await fetch('/api/admin/orgs'); + if (res.ok) setOrgs(await res.json()); + } + + useEffect(() => { + if (loading) return; + if (!admin) { router.replace('/admin/login'); return; } + void load(); + }, [admin, loading, router]); + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const res = await fetch('/api/admin/orgs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed'); + } + setShowAdd(false); + setForm({ slug: '', name: '' }); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + + if (loading) return

Loading...

; + if (!admin) return null; + + return ( +
+
+

Organizations

+ +
+ + {showAdd && ( +
void handleAdd(e)} className="bg-white rounded-xl border p-5 mb-6 flex flex-col gap-4"> +

New Organization

+
+
+ + setForm({ ...form, name: e.target.value })} + className="w-full border rounded-lg px-3 py-2 text-sm" + placeholder="UP Parivaar" + required + /> +
+
+ + setForm({ ...form, slug: e.target.value })} + className="w-full border rounded-lg px-3 py-2 text-sm" + placeholder="org_up_parivaar" + required + /> +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + +
+ + + + + + + + + + + {orgs.map((org: any) => ( + + + + + + + ))} + +
NameSlugChaptersOrg Admins
+ + {org.name} + + {org.slug}{org._count?.tenants ?? 0}{org._count?.orgAdmins ?? 0}
+ {orgs.length === 0 &&

No organizations yet.

} +
+
+ ); +} diff --git a/apps/web/app/api/admin/orgs/[id]/admins/route.ts b/apps/web/app/api/admin/orgs/[id]/admins/route.ts new file mode 100644 index 0000000..5666447 --- /dev/null +++ b/apps/web/app/api/admin/orgs/[id]/admins/route.ts @@ -0,0 +1,21 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +async function getSuperToken(): Promise { + const { cookies } = await import('next/headers'); + return (await cookies()).get('tower_super_token')?.value; +} + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getSuperToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/admin/orgs/${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/api/admin/orgs/[id]/route.ts b/apps/web/app/api/admin/orgs/[id]/route.ts new file mode 100644 index 0000000..b7f33fb --- /dev/null +++ b/apps/web/app/api/admin/orgs/[id]/route.ts @@ -0,0 +1,18 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +async function getSuperToken(): Promise { + const { cookies } = await import('next/headers'); + return (await cookies()).get('tower_super_token')?.value; +} + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getSuperToken(); + const res = await fetch(`${getApiBaseUrl()}/admin/orgs/${id}`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} diff --git a/apps/web/app/api/admin/orgs/route.ts b/apps/web/app/api/admin/orgs/route.ts new file mode 100644 index 0000000..2f08179 --- /dev/null +++ b/apps/web/app/api/admin/orgs/route.ts @@ -0,0 +1,29 @@ +import { getApiBaseUrl, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +async function getSuperToken(): Promise { + const { cookies } = await import('next/headers'); + return (await cookies()).get('tower_super_token')?.value; +} + +export async function GET(): Promise { + const token = await getSuperToken(); + const res = await fetch(`${getApiBaseUrl()}/admin/orgs`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} + +export async function POST(req: Request): Promise { + const token = await getSuperToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/admin/orgs`, { + 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/api/admin/tenants/[id]/org/route.ts b/apps/web/app/api/admin/tenants/[id]/org/route.ts new file mode 100644 index 0000000..f4a407c --- /dev/null +++ b/apps/web/app/api/admin/tenants/[id]/org/route.ts @@ -0,0 +1,21 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +async function getSuperToken(): Promise { + const { cookies } = await import('next/headers'); + return (await cookies()).get('tower_super_token')?.value; +} + +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getSuperToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/admin/tenants/${id}/org`, { + method: 'PATCH', + 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/api/auth/org/login/route.ts b/apps/web/app/api/auth/org/login/route.ts new file mode 100644 index 0000000..5b68c81 --- /dev/null +++ b/apps/web/app/api/auth/org/login/route.ts @@ -0,0 +1,19 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request): Promise { + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/auth/org/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const payload = await res.json(); + const headers: Record = {}; + if (res.ok && payload.token) { + headers['Set-Cookie'] = `tower_org_token=${payload.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`; + } + return jsonResponse(payload, res.status, headers); +} diff --git a/apps/web/app/api/auth/org/logout/route.ts b/apps/web/app/api/auth/org/logout/route.ts new file mode 100644 index 0000000..30a6e5a --- /dev/null +++ b/apps/web/app/api/auth/org/logout/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function POST(): Promise { + return jsonResponse({ ok: true }, 200, { + 'Set-Cookie': 'tower_org_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0', + }); +} diff --git a/apps/web/app/api/auth/org/me/route.ts b/apps/web/app/api/auth/org/me/route.ts new file mode 100644 index 0000000..d2ef3e2 --- /dev/null +++ b/apps/web/app/api/auth/org/me/route.ts @@ -0,0 +1,16 @@ +import { getApiBaseUrl, jsonResponse } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const token = await (await import('next/headers')).cookies().then(c => c.get('tower_org_token')?.value); + const res = await fetch(`${getApiBaseUrl()}/auth/org/me`, { + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + cache: 'no-store', + }); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/org/dashboard/route.ts b/apps/web/app/api/org/dashboard/route.ts new file mode 100644 index 0000000..dff0376 --- /dev/null +++ b/apps/web/app/api/org/dashboard/route.ts @@ -0,0 +1,17 @@ +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 GET(): Promise { + const token = await getOrgToken(); + const res = await fetch(`${getApiBaseUrl()}/org/dashboard`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} diff --git a/apps/web/app/api/org/rules/[id]/route.ts b/apps/web/app/api/org/rules/[id]/route.ts new file mode 100644 index 0000000..6485f87 --- /dev/null +++ b/apps/web/app/api/org/rules/[id]/route.ts @@ -0,0 +1,32 @@ +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 PATCH(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/rules/${id}`, { + method: 'PATCH', + 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); +} + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getOrgToken(); + const res = await fetch(`${getApiBaseUrl()}/org/rules/${id}`, { + method: 'DELETE', + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return res.ok ? jsonResponse({ ok: true }, 200) : jsonResponse(await res.json(), res.status); +} diff --git a/apps/web/app/api/org/rules/route.ts b/apps/web/app/api/org/rules/route.ts new file mode 100644 index 0000000..ea88773 --- /dev/null +++ b/apps/web/app/api/org/rules/route.ts @@ -0,0 +1,29 @@ +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 GET(): Promise { + const token = await getOrgToken(); + const res = await fetch(`${getApiBaseUrl()}/org/rules`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} + +export async function POST(req: Request): Promise { + const token = await getOrgToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/org/rules`, { + 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/api/org/tenants/[id]/route.ts b/apps/web/app/api/org/tenants/[id]/route.ts new file mode 100644 index 0000000..bd5fd74 --- /dev/null +++ b/apps/web/app/api/org/tenants/[id]/route.ts @@ -0,0 +1,18 @@ +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 GET(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params; + const token = await getOrgToken(); + const res = await fetch(`${getApiBaseUrl()}/org/tenants/${id}`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} diff --git a/apps/web/app/api/org/tenants/route.ts b/apps/web/app/api/org/tenants/route.ts new file mode 100644 index 0000000..c597ac2 --- /dev/null +++ b/apps/web/app/api/org/tenants/route.ts @@ -0,0 +1,29 @@ +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 GET(): Promise { + const token = await getOrgToken(); + const res = await fetch(`${getApiBaseUrl()}/org/tenants`, { + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + cache: 'no-store', + }); + return jsonResponse(await res.json(), res.status); +} + +export async function POST(req: Request): Promise { + const token = await getOrgToken(); + const body = await req.json().catch(() => ({})); + const res = await fetch(`${getApiBaseUrl()}/org/tenants`, { + 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/api/threads/[id]/route.ts b/apps/web/app/api/threads/[id]/route.ts new file mode 100644 index 0000000..c679836 --- /dev/null +++ b/apps/web/app/api/threads/[id]/route.ts @@ -0,0 +1,13 @@ +import { apiFetch, jsonResponse } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await apiFetch(`/admin/threads/${id}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/threads/route.ts b/apps/web/app/api/threads/route.ts new file mode 100644 index 0000000..c131edd --- /dev/null +++ b/apps/web/app/api/threads/route.ts @@ -0,0 +1,9 @@ +import { apiFetch, jsonResponse } from '../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_req: Request): Promise { + const res = await apiFetch('/admin/threads'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/org/layout.tsx b/apps/web/app/org/layout.tsx new file mode 100644 index 0000000..79410c1 --- /dev/null +++ b/apps/web/app/org/layout.tsx @@ -0,0 +1,3 @@ +export default function OrgLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/org/login/page.tsx b/apps/web/app/org/login/page.tsx new file mode 100644 index 0000000..2d6deb7 --- /dev/null +++ b/apps/web/app/org/login/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useOrgAdmin } from '../../_lib/org-admin-context'; + +export default function OrgLoginPage() { + const { login } = useOrgAdmin(); + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + router.replace('/org'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

TOWER

+

Organization portal

+
void handleSubmit(e)} className="flex flex-col gap-4"> +
+ + setEmail(e.target.value)} + className="w-full border rounded-lg px-3 py-2 text-sm" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full border rounded-lg px-3 py-2 text-sm" + required + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/apps/web/app/org/page.tsx b/apps/web/app/org/page.tsx new file mode 100644 index 0000000..c64b3f1 --- /dev/null +++ b/apps/web/app/org/page.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useOrgAdmin } from '../_lib/org-admin-context'; + +export default function OrgDashboardPage() { + const { orgAdmin, loading } = useOrgAdmin(); + const router = useRouter(); + const [data, setData] = useState(null); + + useEffect(() => { + if (loading) return; + if (!orgAdmin) { router.replace('/org/login'); return; } + fetch('/api/org/dashboard') + .then((r) => r.ok ? r.json() : null) + .then((d) => setData(d)) + .catch(() => {}); + }, [orgAdmin, loading, router]); + + if (loading || !orgAdmin) return

Loading...

; + + return ( +
+

{orgAdmin.organization?.name ?? 'Organization'}

+

{data?.chapterCount ?? '—'} chapters

+ +
+ {(data?.chapters ?? []).map((chapter: any) => ( +
+
+ + {chapter.name} + +

{chapter.slug}

+
+
+
+

{chapter.messageCount}

+

messages

+
+
+

{chapter.memberCount}

+

members

+
+
+

{chapter.adminCount}

+

admins

+
+ + {chapter.isActive ? 'Active' : 'Inactive'} + +
+
+ ))} + {data && data.chapters.length === 0 && ( +

No chapters yet. Add a chapter.

+ )} +
+
+ ); +} diff --git a/apps/web/app/org/rules/page.tsx b/apps/web/app/org/rules/page.tsx new file mode 100644 index 0000000..9f43731 --- /dev/null +++ b/apps/web/app/org/rules/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useOrgAdmin } from '../../_lib/org-admin-context'; + +const MATCH_TYPES = ['HASHTAG', 'PREFIX', 'REACTION_EMOJI']; +const ACTIONS = ['FLAG', 'AUTO_APPROVE', 'SKIP', 'REJECT', 'P1']; + +export default function OrgRulesPage() { + const { orgAdmin, loading } = useOrgAdmin(); + const router = useRouter(); + const [rules, setRules] = useState([]); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ matchType: 'HASHTAG', matchValue: '', action: 'FLAG', priority: 0 }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + async function load() { + const res = await fetch('/api/org/rules'); + if (res.ok) setRules(await res.json()); + } + + useEffect(() => { + if (loading) return; + if (!orgAdmin) { router.replace('/org/login'); return; } + void load(); + }, [orgAdmin, loading, router]); + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const res = await fetch('/api/org/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed'); + } + setShowAdd(false); + setForm({ matchType: 'HASHTAG', matchValue: '', action: 'FLAG', priority: 0 }); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + + async function toggleActive(rule: any) { + await fetch(`/api/org/rules/${rule.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !rule.isActive }), + }); + void load(); + } + + async function deleteRule(id: string) { + if (!confirm('Delete this rule?')) return; + await fetch(`/api/org/rules/${id}`, { method: 'DELETE' }); + void load(); + } + + if (loading || !orgAdmin) return

Loading...

; + + return ( +
+
+
+

Org-wide Rules

+

These rules apply to all chapters and take priority over chapter-specific rules.

+
+ +
+ + {showAdd && ( +
void handleAdd(e)} className="bg-white rounded-xl border p-5 mb-6 flex flex-col gap-4"> +

New Org Rule

+
+
+ + +
+
+ + setForm({ ...form, matchValue: e.target.value })} + className="w-full border rounded-lg px-3 py-2 text-sm" + placeholder="e.g. #emergency" + required + /> +
+
+ + +
+
+ + setForm({ ...form, priority: Number(e.target.value) })} + className="w-full border rounded-lg px-3 py-2 text-sm" + /> +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + +
+ + + + + + + + + + + + + {rules.map((r: any) => ( + + + + + + + + + ))} + +
Match typeMatch valueActionPriorityActive
{r.matchType}{r.matchValue} + {r.action} + {r.priority} + + + +
+ {rules.length === 0 &&

No org rules yet.

} +
+
+ ); +} diff --git a/apps/web/app/org/tenants/[id]/page.tsx b/apps/web/app/org/tenants/[id]/page.tsx new file mode 100644 index 0000000..cf64ed2 --- /dev/null +++ b/apps/web/app/org/tenants/[id]/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useOrgAdmin } from '../../../_lib/org-admin-context'; + +export default function OrgTenantDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const { orgAdmin, loading } = useOrgAdmin(); + const router = useRouter(); + const [tenant, setTenant] = useState(null); + + useEffect(() => { + if (loading) return; + if (!orgAdmin) { router.replace('/org/login'); return; } + fetch(`/api/org/tenants/${id}`) + .then((r) => r.ok ? r.json() : null) + .then((d) => setTenant(d)) + .catch(() => {}); + }, [orgAdmin, loading, router, id]); + + if (loading || !orgAdmin) return

Loading...

; + if (!tenant) return

Chapter not found.

; + + return ( +
+
+ +

{tenant.name}

+

{tenant.slug}

+
+ +
+ {[ + { label: 'Messages', value: tenant._count?.messages }, + { label: 'Members', value: tenant._count?.towerUsers }, + { label: 'Groups', value: tenant._count?.groups }, + ].map((stat) => ( +
+

{stat.value ?? '—'}

+

{stat.label}

+
+ ))} +
+ +
+
+

Chapter Admins

+
+ + + + + + + + + + {(tenant.admins ?? []).map((a: any) => ( + + + + + + ))} + +
EmailRoleJoined
{a.email} + {a.role} + {new Date(a.createdAt).toLocaleDateString()}
+ {tenant.admins?.length === 0 &&

No admins yet.

} +
+
+ ); +} diff --git a/apps/web/app/org/tenants/page.tsx b/apps/web/app/org/tenants/page.tsx new file mode 100644 index 0000000..f8266c6 --- /dev/null +++ b/apps/web/app/org/tenants/page.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useOrgAdmin } from '../../_lib/org-admin-context'; + +export default function OrgTenantsPage() { + const { orgAdmin, loading } = useOrgAdmin(); + const router = useRouter(); + const [tenants, setTenants] = useState([]); + const [showAdd, setShowAdd] = useState(false); + const [form, setForm] = useState({ name: '', slug: '' }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + async function load() { + const res = await fetch('/api/org/tenants'); + if (res.ok) setTenants(await res.json()); + } + + useEffect(() => { + if (loading) return; + if (!orgAdmin) { router.replace('/org/login'); return; } + void load(); + }, [orgAdmin, loading, router]); + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const res = await fetch('/api/org/tenants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message ?? 'Failed to create chapter'); + } + setShowAdd(false); + setForm({ name: '', slug: '' }); + void load(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error'); + } finally { + setSaving(false); + } + } + + if (loading || !orgAdmin) return

Loading...

; + + return ( +
+
+

Chapters

+ {orgAdmin.role === 'ORG_OWNER' && ( + + )} +
+ + {showAdd && ( +
void handleAdd(e)} className="bg-white rounded-xl border p-5 mb-6 flex flex-col gap-4"> +

New Chapter

+
+
+ + setForm({ ...form, name: e.target.value })} + className="w-full border rounded-lg px-3 py-2 text-sm" + required + /> +
+
+ + setForm({ ...form, slug: e.target.value })} + className="w-full border rounded-lg px-3 py-2 text-sm" + placeholder="e.g. tenant_up_parivaar_dallas" + required + /> +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + +
+ + + + + + + + + + + + {tenants.map((t: any) => ( + + + + + + + + ))} + +
NameSlugMessagesMembersStatus
+ + {t.name} + + {t.slug}{t._count?.messages ?? '—'}{t._count?.towerUsers ?? '—'} + + {t.isActive ? 'Active' : 'Inactive'} + +
+ {tenants.length === 0 &&

No chapters yet.

} +
+
+ ); +} diff --git a/apps/web/app/threads/[id]/page.tsx b/apps/web/app/threads/[id]/page.tsx new file mode 100644 index 0000000..2958d6a --- /dev/null +++ b/apps/web/app/threads/[id]/page.tsx @@ -0,0 +1,107 @@ +import Link from 'next/link'; +import { apiFetch } from '../../_lib/api'; + +interface ThreadMessage { + id: string; + content: string; + senderJid: string; + senderName: string | null; + tags: string[]; + status: string; + createdAt: string; +} + +interface ThreadDetail { + id: string; + topic: string | null; + sourceGroupName: string; + messageCount: number; + lastActivityAt: string; + createdAt: string; + messages: ThreadMessage[]; +} + +const STATUS_COLORS: Record = { + APPROVED: 'text-green-600', + PENDING: 'text-amber-600', + REJECTED: 'text-red-600', + RAW: 'text-gray-400', + DNC: 'text-gray-400', + DISTRIBUTED: 'text-blue-600', +}; + +export default async function ThreadDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + let thread: ThreadDetail | null = null; + let error: string | null = null; + + try { + const res = await apiFetch(`/admin/threads/${id}`); + if (res.ok) { + thread = await res.json(); + } else { + error = `API returned ${res.status}`; + } + } catch { + error = 'Failed to load thread'; + } + + if (error || !thread) { + return ( +
+ ← Back to threads +

{error ?? 'Thread not found'}

+
+ ); + } + + return ( +
+ ← Back to threads + +
+

+ {thread.topic ?? `Thread ${thread.id.slice(0, 8)}`} +

+

+ {thread.sourceGroupName} · {thread.messageCount} messages · last activity {new Date(thread.lastActivityAt).toLocaleDateString()} +

+
+ +
+ {thread.messages.map((msg) => ( +
+
+
+ {msg.senderName ?? msg.senderJid} + · + {new Date(msg.createdAt).toLocaleString()} +
+
+ + {msg.status} + + + View + +
+
+

{msg.content}

+ {msg.tags.length > 0 && ( +
+ {msg.tags.map((tag) => ( + {tag} + ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/threads/page.tsx b/apps/web/app/threads/page.tsx new file mode 100644 index 0000000..cf6c4e1 --- /dev/null +++ b/apps/web/app/threads/page.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import { apiFetch } from '../_lib/api'; + +interface ThreadSummary { + id: string; + topic: string | null; + sourceGroupName: string; + messageCount: number; + lastActivityAt: string; + createdAt: string; +} + +export default async function ThreadsPage() { + let threads: ThreadSummary[] = []; + let error: string | null = null; + + try { + const res = await apiFetch('/admin/threads'); + if (res.ok) { + threads = await res.json(); + } else { + error = `API returned ${res.status}`; + } + } catch { + error = 'Failed to load threads'; + } + + return ( +
+

Threads

+ + {error && ( +

{error}

+ )} + + {!error && threads.length === 0 && ( +

No threads yet. Threads are created automatically when members reply to messages.

+ )} + + {threads.length > 0 && ( +
+ {threads.map((thread) => ( + +
+

+ {thread.topic ?? `Thread ${thread.id.slice(0, 8)}`} +

+

{thread.sourceGroupName}

+
+
+ + {thread.messageCount} {thread.messageCount === 1 ? 'message' : 'messages'} + +

+ {new Date(thread.lastActivityAt).toLocaleDateString()} +

+
+ + ))} +
+ )} +
+ ); +}