From f671c8e7bf2837f9a143678dc9fb51eee8231f27 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:43:08 +0200 Subject: [PATCH 1/2] fix(provision): separate an appended registry entry with a blank line (#87) The generated entry butted directly against the previous tenant's trailing comment, so the comment read as if it introduced the new tenant. Two newlines instead of one; the ReDoS-safe trimEnd() approach is unchanged. Co-authored-by: Claude Opus 5 --- lib/provisioning.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 4dd5de7..a8dd53e 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -77,8 +77,10 @@ export async function openProvisioningPr(input: TenantProvisionInput): Promise<{ const entry = buildTenantRegistryEntry(input); // trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add - // exactly one before the appended entry — avoids the ReDoS-prone `\n*$`. - const updated = `${current.trimEnd()}\n${entry}\n`; + // exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the + // new tenant from butting against the previous one's trailing comment, which + // reads as if it belongs to the new entry. + const updated = `${current.trimEnd()}\n\n${entry}\n`; // Branch off BASE's tip. const baseRef = await gh<{ object: { sha: string } }>( From d25376742a8cfb66813406fbad94abc9d44238fb Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:51:17 +0200 Subject: [PATCH 2/2] feat(admin): grouped sidebar nav + catalog-driven provisioning pickers (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin): grouped sidebar nav + catalog-driven provisioning pickers Three owner-reported navigation/entry problems, all versions of "the UI knows less than the code does". **Nav.** Ten equal-weight links in a header row is a list to scan, not a structure to navigate. Admin now gets a grouped sidebar — pipeline (who wants to buy) / tenants (who is running) / money / system — sticky beside the content on desktop. Below `lg` it renders as one compact wrapped row instead of the vertical column: a sidebar on a phone means scrolling past ten links to reach the page, which is worse than what it replaced. Group labels are dropped there because the grouping is a desktop scanning aid, not information. The partner dashboard keeps the inline header nav — it has one to three destinations. **Modules + languages were free-text comma lists.** They asked the founder to remember eight module ids and ten locale codes, then rejected typos after the fact. Both are now checkboxes generated from the ADR-010 catalog, submitting the same values (the action reads `getAll` and joins, so the schema and the registry entry are unchanged — a plain `get()` would have silently taken the first box). Two things fell out of building it: * The selection now shows its **monthly list price**, resolved through `quoteModules`, so the number the founder types into /admin/onboard comes from the catalog instead of a mental sum. Ticking kitchen-board + cashier + printing reads "€ 45,00 — as bundle «counter» (à la carte € 52,00)". * Picking a third language priced `extra-languages` but did not record it, so the tenant would have been billed for a module their registry entry never claimed. The hidden input now follows the quote. `en` is fixed (the tenant app's fallback locale) and `core` is fixed (every instance runs it): both are stated rather than offered, and carried by hidden inputs since a disabled checkbox submits nothing. Verified in a browser against the real Tailwind build — desktop and mobile, light and dark — and by reading the serialized FormData back out: `core,kitchen-board,cashier,printing,extra-languages` / `en,nl,fr`. Co-Authored-By: Claude Opus 5 * fix(provision): keep non-string form entries out of the registry (Sonar S6551/S7776) getAll() is typed (string | File)[], so a crafted multipart POST could put a File under `modules` and join() would stringify it to "[object Object]" — straight into a registry proposal. Filtered to strings behind a named helper that says what it is. Language lookup moves to a Set while nearby. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- app/(control)/admin/layout.tsx | 42 +++++--- components/control/ControlShell.tsx | 72 +++++++++----- components/control/ProvisionForm.tsx | 26 +++-- components/control/ProvisionPicker.tsx | 130 +++++++++++++++++++++++++ components/control/SidebarNav.tsx | 79 +++++++++++++++ lib/actions/provisioning-actions.ts | 17 +++- lib/module-catalog.ts | 39 ++++++++ messages/ar.json | 17 +++- messages/de.json | 17 +++- messages/en.json | 17 +++- messages/fr.json | 17 +++- messages/nl.json | 17 +++- messages/tr.json | 17 +++- tests/unit/module-catalog.test.ts | 35 +++++++ 14 files changed, 473 insertions(+), 69 deletions(-) create mode 100644 components/control/ProvisionPicker.tsx create mode 100644 components/control/SidebarNav.tsx diff --git a/app/(control)/admin/layout.tsx b/app/(control)/admin/layout.tsx index f0741a7..16242e3 100644 --- a/app/(control)/admin/layout.tsx +++ b/app/(control)/admin/layout.tsx @@ -8,22 +8,42 @@ export default async function AdminLayout({ children }: { children: React.ReactN const locale = await controlLocale(); const t = await getTranslations({ locale, namespace: "control.shell" }); + // Grouped by the question each section answers — who wants to buy, who is + // running, what is being charged, what happened — not by the order the pages + // were built. Ten equal-weight links in a header row was a list to scan + // rather than a structure to navigate. return ( {children} diff --git a/components/control/ControlShell.tsx b/components/control/ControlShell.tsx index 2759cb7..9152da2 100644 --- a/components/control/ControlShell.tsx +++ b/components/control/ControlShell.tsx @@ -1,44 +1,58 @@ import Link from "next/link"; import BrandMark from "@/components/BrandMark"; import { logoutAction } from "@/lib/actions/auth-actions"; +import SidebarNav, { type NavGroup, type NavItem } from "./SidebarNav"; /** Shared chrome for the partner dashboard and founder admin. Labels arrive - * translated from the server layouts (control-plane i18n, sofra #9). */ + * translated from the server layouts (control-plane i18n, sofra #9). + * + * Two nav shapes, because the two surfaces are different sizes: the partner + * dashboard has one to three destinations and keeps them inline in the header + * (`nav`), while admin has ten and gets a grouped sidebar (`groups`) — ten + * equal-weight links in a row is a list to scan, not a structure to navigate. + * The sidebar collapses to a scrollable strip under the header on small + * screens rather than hiding behind a toggle: no JS, nothing to discover. */ export default function ControlShell({ title, nav, + groups, userLabel, signOutLabel, children, }: { title: string; - nav: { href: string; label: string }[]; + nav?: NavItem[]; + groups?: NavGroup[]; userLabel: string; signOutLabel: string; children: React.ReactNode; }) { + const inlineNav = nav ?? []; return ( <>
-
+
- + SofraPiwas {title} - + {inlineNav.length > 0 && ( + + )}
@@ -54,16 +68,28 @@ export default function ControlShell({
- {/* Mobile nav row */} - + {inlineNav.length > 0 && ( + /* Mobile nav row (inline shape only — the sidebar has its own) */ + + )}
-
{children}
+ + {groups ? ( +
+ +
{children}
+
+ ) : ( +
{children}
+ )} ); } diff --git a/components/control/ProvisionForm.tsx b/components/control/ProvisionForm.tsx index b8b63a9..d6eec73 100644 --- a/components/control/ProvisionForm.tsx +++ b/components/control/ProvisionForm.tsx @@ -7,6 +7,7 @@ import { type ProvisionActionState, } from "@/lib/actions/provisioning-actions"; import ActionError from "./ActionError"; +import ProvisionPicker from "./ProvisionPicker"; /** * Admin form that proposes a new tenant (ADR-012): submits to the server action @@ -75,21 +76,16 @@ export default function ProvisionForm({ disabled }: Readonly<{ disabled?: boolea aria-label={t("provision.currency")} className="input-primary" /> - -