Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ jobs:
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
# Gate :latest on the main branch explicitly, NOT {{is_default_branch}}:
# under GitFlow the repo's default branch is `develop`, so is_default_branch
# is false on the release push to main and :latest never advanced (fix 2026-07-12).
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=sha,format=long
type=ref,event=tag

Expand All @@ -66,8 +69,9 @@ jobs:
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
# Same main-branch gate as the app image above (not {{is_default_branch}}).
tags: |
type=raw,value=migrate,enable={{is_default_branch}}
type=raw,value=migrate,enable=${{ github.ref == 'refs/heads/main' }}
type=sha,format=long,prefix=migrate-

- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
Expand Down
84 changes: 84 additions & 0 deletions .github/workflows/retag-mutable-tags.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: retag-mutable-tags

# Break-glass: re-point the mutable :latest (app) and :migrate (DB-tooling) GHCR
# tags at an already-published immutable release build (:sha-<sha> / :migrate-<sha>)
# without rebuilding — a server-side manifest copy via `docker buildx imagetools
# create`. Use when a release's mutable tags did not advance (e.g. the 2026-07-12
# is_default_branch bug, fixed in build-image.yml) or to roll :latest back to a
# known-good release sha.
#
# Security posture mirrors build-image.yml: the operator-supplied `sha` input is
# passed via env and regex-validated (never interpolated into a run: shell), the
# action is pinned to an immutable SHA, and GITHUB_TOKEN is packages:write only.

on:
workflow_dispatch:
inputs:
sha:
description: 'Full 40-char commit SHA of the release whose :sha-<sha> / :migrate-<sha> images become :latest / :migrate'
required: true
type: string

concurrency:
group: retag-mutable-tags
cancel-in-progress: false

permissions:
contents: read
packages: write

jobs:
retag:
name: re-point :latest and :migrate
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Re-point mutable tags (server-side manifest copy)
env:
SHA: ${{ inputs.sha }}
IMAGE: ghcr.io/${{ github.repository }}
run: |
set -euo pipefail
if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::sha must be a 40-char lowercase hex commit SHA (got: '$SHA')"
exit 1
fi
echo "Re-pointing $IMAGE:latest -> $IMAGE:sha-$SHA"
docker buildx imagetools create --tag "$IMAGE:latest" "$IMAGE:sha-$SHA"
echo "Re-pointing $IMAGE:migrate -> $IMAGE:migrate-$SHA"
docker buildx imagetools create --tag "$IMAGE:migrate" "$IMAGE:migrate-$SHA"

- name: Verify mutable tags resolve to the release image (amd64 manifest)
env:
SHA: ${{ inputs.sha }}
IMAGE: ghcr.io/${{ github.repository }}
run: |
set -euo pipefail
# Compare the linux/amd64 child-manifest digest (what the box actually
# runs), not the top-level index digest — imagetools reserializes the
# index, so index digests can differ even on a correct retag.
amd64() {
docker buildx imagetools inspect "$1" \
--format '{{range .Manifest.Manifests}}{{if eq .Platform.Architecture "amd64"}}{{println .Digest}}{{end}}{{end}}' \
| head -n1
}
fail=0
for pair in "latest:sha-$SHA" "migrate:migrate-$SHA"; do
mut="${pair%%:*}"; imm="${pair#*:}"
md="$(amd64 "$IMAGE:$mut")"; id="$(amd64 "$IMAGE:$imm")"
echo ":$mut amd64 = $md"
echo ":$imm amd64 = $id"
if [[ -n "$md" && "$md" == "$id" ]]; then
echo "OK: :$mut now resolves to :$imm"
else
echo "::error::$IMAGE:$mut amd64 digest ($md) != $IMAGE:$imm ($id)"
fail=1
fi
done
exit "$fail"
27 changes: 26 additions & 1 deletion app/(control)/admin/onboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,32 @@ import { getTranslations } from "next-intl/server";
import { requireAdmin } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { mollieConfigured } from "@/lib/mollie";
import { db } from "@/lib/db";
import { loadTenantRegistry } from "@/lib/tenant-registry";
import { toOnboardTenants, type OnboardTenant } from "@/lib/onboard-tenants";
import OnboardPartnerForm from "@/components/control/OnboardPartnerForm";

// The registry file changes underneath us (rsync on deploy-repo push) and the
// billing join must reflect the live DB — always re-read, never a build snapshot
// (mirrors /admin/tenants).
export const dynamic = "force-dynamic";

export default async function AdminOnboardPage() {
await requireAdmin();
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.admin" });

// Registry⋈billing join (same pattern as /admin/tenants): compute which
// registry tenants are partner-free so the form can offer a picker. If the
// registry can't be read, fall back to the free-text form + an inline note.
const registry = await loadTenantRegistry();
let tenants: OnboardTenant[] | undefined;
if (registry.ok) {
const billings = await db.tenantBilling.findMany({ select: { tenantSlug: true } });
const onboardedSlugs = new Set(billings.map((b) => b.tenantSlug));
tenants = toOnboardTenants(registry.tenants, onboardedSlugs);
}

return (
<div className="grid gap-10">
<div>
Expand All @@ -22,8 +41,14 @@ export default async function AdminOnboardPage() {
</p>
)}

{!registry.ok && (
<p className="hand-drawn-border bg-card p-4 font-label text-craft-error-text">
{t("onboard.registryUnavailable", { error: registry.error })}
</p>
)}

<section className="hand-drawn-border bg-card p-5">
<OnboardPartnerForm />
<OnboardPartnerForm tenants={tenants} />
</section>
</div>
);
Expand Down
125 changes: 106 additions & 19 deletions components/control/OnboardPartnerForm.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
"use client";

import { useActionState } from "react";
import { useActionState, useState } from "react";
import { useTranslations } from "next-intl";
import { onboardPartnerAction, type OnboardActionState } from "@/lib/actions/onboarding-actions";
import { type OnboardTenant, isOnboardable } from "@/lib/onboard-tenants";
import ActionError from "./ActionError";
import CopyField from "./CopyField";

export default function OnboardPartnerForm() {
/**
* Admin onboarding form. When the page can read the tenant registry it passes
* `tenants` and the form drives the tenant from a registry picker (partner-free
* by default, pre-filling restaurant name + go-live date). When the registry is
* unavailable `tenants` is undefined and it falls back to free-text inputs — the
* server action + slug regex are identical in both modes.
*/
export default function OnboardPartnerForm({ tenants }: Readonly<{ tenants?: OnboardTenant[] }>) {
const t = useTranslations("control.admin");
const [state, action, pending] = useActionState<OnboardActionState, FormData>(
onboardPartnerAction,
{},
);

// Registry-driven picker state. `slug` also keys the pre-filled restaurant
// name + date inputs so selecting a tenant remounts them with fresh defaults
// while leaving them editable.
const [slug, setSlug] = useState("");
const [showAll, setShowAll] = useState(false);
const registryMode = Array.isArray(tenants);
const selectable = tenants?.filter(isOnboardable) ?? [];
const visible = showAll ? (tenants ?? []) : selectable;
const selected = tenants?.find((tn) => tn.slug === slug);

const optionLabel = (tn: OnboardTenant) => {
const cityPart = tn.city ? ` · ${tn.city}` : "";
let badge = "";
if (tn.onboarded) badge = ` (${t("onboard.onboardedBadge")})`;
else if (tn.status !== "active") badge = ` (${tn.status})`;
return `${tn.slug} — ${tn.name}${cityPart}${badge}`;
};

return (
<form action={action} className="grid gap-3 sm:grid-cols-2">
<input
Expand All @@ -32,22 +58,76 @@ export default function OnboardPartnerForm() {
aria-label={t("onboard.email")}
className="input-primary"
/>
<input
name="tenantSlug"
required
pattern="[a-z0-9][a-z0-9-]{1,30}"
placeholder={t("onboard.slug")}
aria-label={t("onboard.slug")}
className="input-primary"
/>
<input
name="restaurantName"
required
maxLength={200}
placeholder={t("onboard.restaurantName")}
aria-label={t("onboard.restaurantName")}
className="input-primary"
/>

{registryMode ? (
<>
<label className="sm:col-span-2 grid gap-1 font-label text-sm text-muted-foreground">
{t("onboard.tenant")}
<select
name="tenantSlug"
required
value={slug}
onChange={(e) => setSlug(e.target.value)}
aria-label={t("onboard.tenant")}
className="input-primary"
>
<option value="" disabled>
{t("onboard.tenantPlaceholder")}
</option>
{visible.map((tn) => (
<option key={tn.slug} value={tn.slug} disabled={!isOnboardable(tn)}>
{optionLabel(tn)}
</option>
))}
</select>
</label>
<label className="sm:col-span-2 flex items-center gap-2 font-label text-sm text-muted-foreground">
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} />
{t("onboard.showAll")}
</label>
{selectable.length === 0 && !showAll && (
<p className="sm:col-span-2 font-label text-sm text-craft-error-text">
{t("onboard.noTenants")}
</p>
)}
{selected && (
<p className="sm:col-span-2 font-label text-sm text-muted-foreground">
{[selected.city, selected.currency ?? "EUR"].filter(Boolean).join(" · ")}
{selected.liveSince ? ` · ${t("onboard.liveSincePrefix")} ${selected.liveSince}` : ""}
</p>
)}
<input
key={slug || "none"}
name="restaurantName"
required
maxLength={200}
defaultValue={selected?.name ?? ""}
placeholder={t("onboard.restaurantName")}
aria-label={t("onboard.restaurantName")}
className="input-primary sm:col-span-2"
/>
</>
) : (
<>
<input
name="tenantSlug"
required
pattern="[a-z0-9][a-z0-9-]{1,30}"
placeholder={t("onboard.slug")}
aria-label={t("onboard.slug")}
className="input-primary"
/>
<input
name="restaurantName"
required
maxLength={200}
placeholder={t("onboard.restaurantName")}
aria-label={t("onboard.restaurantName")}
className="input-primary"
/>
</>
)}

<input
name="amount"
type="number"
Expand All @@ -70,7 +150,14 @@ export default function OnboardPartnerForm() {
</select>
<label className="sm:col-span-2 grid gap-1 font-label text-sm text-muted-foreground">
{t("onboard.liveSince")}
<input name="liveSince" type="date" aria-label={t("onboard.liveSince")} className="input-primary" />
<input
key={`ls-${slug || "none"}`}
name="liveSince"
type="date"
defaultValue={registryMode ? (selected?.liveSince ?? "") : undefined}
aria-label={t("onboard.liveSince")}
className="input-primary"
/>
</label>
<div className="sm:col-span-2 flex flex-wrap items-center gap-4">
<button type="submit" disabled={pending} className="btn-primary disabled:opacity-60">
Expand Down
46 changes: 46 additions & 0 deletions lib/onboard-tenants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Shape + join logic for the admin onboarding tenant picker. The page (server)
// builds this list from the registry (lib/tenant-registry.ts) joined with the
// TenantBilling rows by slug — the same registry⋈billing join /admin/tenants
// does — and hands it to <OnboardPartnerForm /> (client). Kept as a plain
// module (no "use server"/"use client") so both surfaces import the type + the
// pure predicates, and so the join stays unit-testable.

import type { RegistryTenant } from "@/lib/tenant-registry";

/** A registry tenant flattened for the onboarding picker: the fields the admin
* needs to choose one, plus whether it already has a billing anchor. */
export type OnboardTenant = {
slug: string;
name: string;
city?: string;
currency?: string;
status: string;
/** Registry `live_since` (YYYY-MM-DD) when present — pre-fills the date. */
liveSince?: string;
/** True when a TenantBilling already exists for this slug (not partner-free). */
onboarded: boolean;
};

/** Only a partner-free (no billing) AND active tenant can be onboarded. Retired
* and already-onboarded tenants are shown for context but never selectable. */
export function isOnboardable(t: OnboardTenant): boolean {
return t.status === "active" && !t.onboarded;
}

/** Flatten registry tenants into picker rows, stamping the partner-free flag
* from the set of slugs that already have a TenantBilling. Input order (the
* registry loader sorts by slug) is preserved. */
export function toOnboardTenants(
tenants: RegistryTenant[],
onboardedSlugs: Set<string>,
): OnboardTenant[] {
return tenants.map((t) => ({
slug: t.slug,
name: t.name,
city: t.city,
currency: t.currency,
status: t.status,
liveSince: t.live_since,
onboarded: onboardedSlugs.has(t.slug),
}));
}
16 changes: 16 additions & 0 deletions lib/tenant-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ const tenantSchema = z.object({
template: z.enum(["classic", "craft"]).optional(),
admin_email: z.string().optional(),
city: z.string().optional(),
// Go-live date (YYYY-MM-DD), optional — the durable source for the onboard
// form's "Live since" pre-fill (deploy repo owns the value; read-only here).
// Absent for tenants provisioned before the field existed. A malformed value
// fails the whole load (same fail-loud contract as `template`), surfaced as
// the registry-unavailable banner rather than silently pre-filling a bad date.
// The round-trip refine also rejects an impossible-but-format-valid date
// (e.g. 2026-02-31, which `new Date` silently rolls over to Mar 3) — mirrors
// onboardSchema.liveSince, so the pre-fill can never carry a phantom day.
live_since: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "live_since must be YYYY-MM-DD")
.refine((v) => {
const d = new Date(`${v}T00:00:00.000Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
}, "live_since must be a real calendar date")
.optional(),
});

const registrySchema = z.object({
Expand Down
9 changes: 8 additions & 1 deletion messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,14 @@
"create": "إنشاء رابط الدعوة",
"creating": "جارٍ الإنشاء…",
"created": "تم إعداد الشريك. شارك رابط الدعوة هذا:",
"inviteNote": "شاركه مع الشريك لتعيين كلمة المرور وبدء الاشتراك."
"inviteNote": "شاركه مع الشريك لتعيين كلمة المرور وبدء الاشتراك.",
"tenant": "المستأجر",
"tenantPlaceholder": "اختر مستأجرًا…",
"showAll": "إظهار المستأجرين المُعدّين والمُوقَفين",
"onboardedBadge": "مُعدّ بالفعل",
"liveSincePrefix": "مباشر منذ",
"noTenants": "لا يوجد مستأجرون بلا شريك — فعّل الخيار لعرض المُعدّين والمُوقَفين.",
"registryUnavailable": "سجل المستأجرين غير متاح ({error}) — أدخل بيانات المستأجر يدويًا أدناه."
}
},
"errors": {
Expand Down
Loading
Loading