Files
portal/src/app/[slug]/settings/page.tsx
T
Sharang Parnerkar 91a655b6df
ci / image (pull_request) Has been skipped
ci / test (pull_request) Failing after 5m25s
ci / shared (pull_request) Successful in 27s
ci / e2e (pull_request) Has been skipped
feat(portal): M10.2 — restyle Products + Org + Team + Billing + Audit + SSO
Six existing customer-area shells under [slug]/* rebuilt against the
handoff design (sections §2/§4/§5/§6/§7/§8). Every screen reuses the
new Panel / Monogram / Sev primitives and the ledger-table token system
so the visual contract stays single-source-of-truth in globals.css.

* `[slug]/settings` (Organization, IT_ADMIN) — legal entity dl, primary
  contact card, plan & seats meter, products subscribed kv-list
  (ENTITLED green dot / TRIALING amber dot).
* `[slug]/settings/users` (Team, IT_ADMIN) — bracketed member ledger
  with role chips, last-active mono dim, active/invited dot status.
  Invite affordance present, modal wiring deferred.
* `[slug]/billing` (Billing, CXO + FINANCE + IT_ADMIN) — current plan
  card with monthly net + 19% VAT, seats + evidence-storage meters,
  payment method block that swaps to "Payment failed → Re-activate"
  when tenant.status is frozen, full invoices ledger with paid/due dot.
* `[slug]/audit` (Audit log, LEGAL + IT_ADMIN) — filter bar (search +
  event-type chip toggles + product select), ledger table with denied
  red dot, footer count + retention note.
* `[slug]/settings/integrations` (SSO, IT_ADMIN) — read-only OIDC
  summary pulling from KEYCLOAK_ISSUER / KEYCLOAK_CLIENT_ID, IdP-group→
  role mapping table.
* `[slug]/products` (Products index, USER+) — 2x2 product grid with
  live cards (entitled + trialing chips) and "Coming soon" dashed
  placeholders, plus a cross-product findings table with filter chips.

Plus a new `NotAllowed` 403 surface in the same ledger language that
replaces the inline "NotAuthorized" message used by the old shells, so
forbidden routes still look like the rest of the portal.

Every page goes through `getPortalSession()` so `BP_DEV_FIXTURE` still
swaps between admin / user / trial / frozen / archived without
Keycloak. Every screen returns 200 against
`BP_DEV_FIXTURE=admin-acme pnpm dev`.

Still to come on this branch:
* Workflows editor (palette + canvas + inspector + drag-wiring)
* ⌘K command palette + toasts
* Product launch detail (per-product page)
* Login redesign (mock SSO picker + violet gradient panel)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:35:25 +02:00

119 lines
5.3 KiB
TypeScript

import { canSee } from "@/lib/session";
import { getPortalSession } from "@/lib/get-session";
import { loadTenantForShell } from "@/lib/portal-data";
import { Panel } from "@/components/portal/Panel";
import { Monogram } from "@/components/portal/Monogram";
import { NotAllowed } from "@/components/portal/NotAllowed";
// "Organization" — IT_ADMIN only.
export default async function OrganizationPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await getPortalSession();
if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const subscribed = t.products.filter((p) => t.entitled.includes(p.id));
const trialing = t.products.filter((p) => t.trialing.includes(p.id));
const seatsLeft = t.seats.total - t.seats.used;
const pct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
return (
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">Organization</div>
<div className="page-sub">
Tenant profile, entitlements & primary contact
</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Export profile</button>
<button type="button" className="btn btn-primary">Edit details</button>
</div>
</div>
<div className="grid g-12">
<div className="span-7 col" style={{ gap: 12 }}>
<Panel title="Legal entity" bracket>
<dl className="dl">
<dt>Legal name</dt><dd>{t.name}</dd>
<dt>Form</dt><dd className="mono">{t.legalType}</dd>
<dt>Registered</dt><dd>{t.city} · {t.country}</dd>
<dt>VAT ID</dt><dd className="mono">{t.vat}</dd>
<dt>Tenant ID</dt><dd className="mono">{t.id}</dd>
<dt>Customer since</dt><dd className="mono">{t.since}</dd>
</dl>
</Panel>
<Panel title="Primary contact">
<div className="row" style={{ gap: 12 }}>
<span className="avatar" style={{ width: 38, height: 38, fontSize: 13 }}>
{t.contact.split(" ").map((s) => s[0]).join("")}
</span>
<div>
<div style={{ fontWeight: 600 }}>{t.contact}</div>
<div className="mono muted" style={{ fontSize: 12 }}>{t.contactEmail}</div>
</div>
<span className="spacer" />
<span className="tag"><span className="dot accent" /> ADMIN OWNER</span>
</div>
</Panel>
</div>
<div className="span-5 col" style={{ gap: 12 }}>
<Panel title="Plan & seats">
<div className="row between" style={{ alignItems: "flex-start", marginBottom: 14 }}>
<div>
<div className="eyebrow">PLAN</div>
<div style={{ fontSize: 17, fontWeight: 600, marginTop: 3 }}>{t.plan}</div>
<div className="mono muted" style={{ fontSize: 11 }}>{t.planCode}</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="eyebrow">RENEWS</div>
<div className="mono" style={{ fontSize: 14, marginTop: 3, color: t.renewal === "overdue" ? "var(--danger)" : "inherit" }}>{t.renewal}</div>
</div>
</div>
<div className="row between" style={{ marginBottom: 6 }}>
<span className="label-micro">SEATS</span>
<span className="mono" style={{ fontSize: 12 }}>{t.seats.used} / {t.seats.total}</span>
</div>
<div className="meter"><span style={{ width: `${pct}%` }} /></div>
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 6 }}>{seatsLeft} seats available</div>
</Panel>
<Panel title="Products subscribed" tail={<span className="label-micro">{subscribed.length} active</span>} pad={false}>
<div className="kv-list" style={{ padding: "2px 14px" }}>
{subscribed.map((p) => (
<div className="kv" key={p.id}>
<span className="row" style={{ gap: 9 }}>
<Monogram text={p.mono} size={24} />
<span className="kvk" style={{ fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap" }}>{p.name}</span>
</span>
<span className="tag"><span className="dot ok" /> ENTITLED</span>
</div>
))}
{trialing.map((p) => (
<div className="kv" key={p.id}>
<span className="row" style={{ gap: 9 }}>
<Monogram text={p.mono} size={24} />
<span className="kvk" style={{ fontWeight: 500, color: "var(--ink)" }}>{p.name}</span>
</span>
<span className="tag"><span className="dot warn" /> TRIALING</span>
</div>
))}
{subscribed.length === 0 && trialing.length === 0 ? (
<div className="muted" style={{ padding: "12px 0", fontSize: 12 }}>No active products.</div>
) : null}
</div>
</Panel>
</div>
</div>
</div>
);
}