);
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 875a6bf..1f9c61c 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -238,6 +238,17 @@ export async function clearEncryptedSecrets(requestId: string): Promise {
);
}
+/**
+ * Wrapper around domain-check.ts that injects the portal's connection pool.
+ * Kept here so route handlers don't need direct access to the pool.
+ */
+export async function checkDuplicateDomain(email: string) {
+ await ensureSchema();
+ // Lazy import to keep db.ts free of fetch/AbortSignal at module load time.
+ const { checkRegistrationDomain } = await import("./domain-check");
+ return checkRegistrationDomain(getPool(), email);
+}
+
/**
* Mark a tenant request as "deleted" when the associated tenant CR is deleted.
* This allows the customer to re-submit the onboarding wizard.
diff --git a/src/lib/domain-check.ts b/src/lib/domain-check.ts
new file mode 100644
index 0000000..2cccc4c
--- /dev/null
+++ b/src/lib/domain-check.ts
@@ -0,0 +1,266 @@
+/**
+ * Domain-uniqueness check for company registration.
+ *
+ * Goal: prevent two people from the same company creating two separate
+ * ZITADEL orgs. If alice@acme.ch registers Acme GmbH, then later
+ * bob@acme.ch tries to register Acme Holding AG, we should block bob and
+ * tell him to ask alice for an invite.
+ *
+ * Strategy:
+ * 1. Extract the domain from the submitted email address.
+ * 2. If the domain is in PUBLIC_EMAIL_DOMAINS, skip the check entirely
+ * (gmail/outlook/etc. are not company identifiers — many independent
+ * personal/sole-proprietor registrations may share gmail.com).
+ * 3. Otherwise, look up tenant_requests with status NOT IN
+ * ('rejected', 'deleted'). A domain is "in use" if any active row's
+ * contact_email shares that domain.
+ * 4. As a secondary check, query ZITADEL for orgs whose primary verified
+ * domain matches. This catches orgs created outside the portal flow
+ * (manually in ZITADEL console, or by an earlier bootstrap script).
+ * The primary-domain check is BEST-EFFORT — if ZITADEL is unreachable
+ * or returns an unexpected shape, we log and skip. The DB check is
+ * authoritative for portal-created orgs and that's what matters most.
+ *
+ * Returns the matching domain (lowercased) if a duplicate is found, else
+ * null. The caller turns that into a 409 response with a localized error.
+ */
+
+import { Pool } from "pg";
+
+// ---------------------------------------------------------------------------
+// Public email-provider blocklist
+// ---------------------------------------------------------------------------
+
+/**
+ * Domains where personal accounts dominate. Registrations from these are
+ * allowed to coexist independently — we don't treat "two gmail.com users"
+ * as the same company.
+ *
+ * Conservative list focused on Switzerland + major international providers.
+ * Adding to this list reduces false positives; removing increases them.
+ * Anything not on this list is treated as a corporate domain.
+ */
+export const PUBLIC_EMAIL_DOMAINS: ReadonlySet = new Set([
+ // Global
+ "gmail.com",
+ "googlemail.com",
+ "outlook.com",
+ "outlook.de",
+ "hotmail.com",
+ "hotmail.de",
+ "hotmail.fr",
+ "hotmail.it",
+ "live.com",
+ "msn.com",
+ "yahoo.com",
+ "yahoo.de",
+ "yahoo.fr",
+ "yahoo.it",
+ "icloud.com",
+ "me.com",
+ "mac.com",
+ "proton.me",
+ "protonmail.com",
+ "pm.me",
+ "tutanota.com",
+ "tutanota.de",
+ "tuta.io",
+ "fastmail.com",
+ "zoho.com",
+ "aol.com",
+
+ // Switzerland
+ "bluewin.ch",
+ "gmx.ch",
+ "gmx.com",
+ "gmx.net",
+ "gmx.de",
+ "gmx.at",
+ "hispeed.ch",
+ "sunrise.ch",
+ "swissonline.ch",
+ "vtxnet.ch",
+ "vtx.ch",
+ "tele2.ch",
+ "freesurf.ch",
+ "bluemail.ch",
+ "hotmail.ch",
+ "yahoo.ch",
+ "mail.ch",
+
+ // Germany / Austria (common in DACH region)
+ "web.de",
+ "t-online.de",
+ "freenet.de",
+ "1und1.de",
+ "aon.at",
+
+ // France / Italy
+ "orange.fr",
+ "free.fr",
+ "laposte.net",
+ "wanadoo.fr",
+ "sfr.fr",
+ "libero.it",
+ "tiscali.it",
+ "alice.it",
+ "virgilio.it",
+]);
+
+// ---------------------------------------------------------------------------
+// Domain extraction
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract the lowercased domain from an email address. Returns null if the
+ * input is not a well-formed email (defense in depth — Zod already validates
+ * the format upstream).
+ */
+export function extractEmailDomain(email: string): string | null {
+ const at = email.lastIndexOf("@");
+ if (at === -1 || at === email.length - 1) return null;
+ const domain = email.slice(at + 1).trim().toLowerCase();
+ if (!domain || !domain.includes(".")) return null;
+ return domain;
+}
+
+/**
+ * True if the domain belongs to a public email provider where multiple
+ * independent registrations should be allowed.
+ */
+export function isPublicEmailDomain(domain: string): boolean {
+ return PUBLIC_EMAIL_DOMAINS.has(domain.toLowerCase());
+}
+
+// ---------------------------------------------------------------------------
+// Database check
+// ---------------------------------------------------------------------------
+
+/**
+ * Look up active tenant_requests whose contact_email shares the given domain.
+ * Active = status NOT IN ('rejected', 'deleted').
+ *
+ * Uses LOWER() on both sides to handle any historical case inconsistency in
+ * stored emails. The pattern '%@' is anchored so 'acme.ch' does not
+ * match 'notacme.ch' or 'acme.ch.evil.com'.
+ */
+async function findDuplicateInDb(
+ pool: Pool,
+ domain: string
+): Promise {
+ const result = await pool.query<{ count: string }>(
+ `SELECT COUNT(*) AS count FROM tenant_requests
+ WHERE LOWER(contact_email) LIKE $1
+ AND status NOT IN ('rejected', 'deleted')`,
+ [`%@${domain.toLowerCase()}`]
+ );
+ return Number(result.rows[0]?.count ?? 0) > 0;
+}
+
+// ---------------------------------------------------------------------------
+// ZITADEL check (secondary, best-effort)
+// ---------------------------------------------------------------------------
+
+/**
+ * Search ZITADEL orgs by primary verified domain.
+ *
+ * Uses the v2 OrganizationService.ListOrganizations API:
+ * POST {ZITADEL_URL}/v2/organizations/_search
+ *
+ * Filter shape (per ZITADEL v2 API): an `organizationDomain` query that
+ * matches against verified domain. Method is EQUALS and case-insensitive.
+ *
+ * Returns true if at least one org matches. Returns false on any error
+ * (network, auth, schema mismatch) — we log and let the DB check be
+ * authoritative. The portal must not block legitimate registrations because
+ * ZITADEL had a hiccup.
+ */
+async function findDuplicateInZitadel(domain: string): Promise {
+ const ZITADEL_URL = process.env.ZITADEL_ISSUER;
+ const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT;
+ if (!ZITADEL_URL || !ZITADEL_SA_PAT) {
+ console.warn("ZITADEL env not configured, skipping org-domain check");
+ return false;
+ }
+
+ try {
+ const res = await fetch(`${ZITADEL_URL}/v2/organizations/_search`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ Authorization: `Bearer ${ZITADEL_SA_PAT}`,
+ },
+ body: JSON.stringify({
+ queries: [
+ {
+ organizationDomain: {
+ domain,
+ method: "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE",
+ },
+ },
+ ],
+ // Limit + sort: we only need to know whether ANY org has this domain
+ pagination: { limit: 1 },
+ }),
+ // Belt: hard timeout so a hung ZITADEL doesn't stall registration
+ signal: AbortSignal.timeout(5000),
+ });
+
+ if (!res.ok) {
+ console.warn(
+ `ZITADEL org-domain search returned ${res.status}, skipping check`
+ );
+ return false;
+ }
+
+ const data = (await res.json()) as {
+ result?: Array<{ id?: string; name?: string }>;
+ };
+ return Array.isArray(data.result) && data.result.length > 0;
+ } catch (err) {
+ console.warn("ZITADEL org-domain search failed, skipping check:", err);
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public entry point
+// ---------------------------------------------------------------------------
+
+export interface DuplicateCheckResult {
+ /** True if registration must be blocked. */
+ blocked: boolean;
+ /** The domain that was matched (lowercased). Set when blocked = true. */
+ domain?: string;
+}
+
+/**
+ * Run the full duplicate-domain check for a registration request.
+ *
+ * Order:
+ * - Parse domain. Invalid → not blocked (Zod already failed if so;
+ * this is just defensive).
+ * - Public domain → not blocked.
+ * - DB hit → blocked.
+ * - ZITADEL hit → blocked.
+ * - Otherwise → not blocked.
+ */
+export async function checkRegistrationDomain(
+ pool: Pool,
+ email: string
+): Promise {
+ const domain = extractEmailDomain(email);
+ if (!domain) return { blocked: false };
+ if (isPublicEmailDomain(domain)) return { blocked: false };
+
+ if (await findDuplicateInDb(pool, domain)) {
+ return { blocked: true, domain };
+ }
+
+ if (await findDuplicateInZitadel(domain)) {
+ return { blocked: true, domain };
+ }
+
+ return { blocked: false };
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..755dd45
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,118 @@
+/**
+ * Locale-aware date/time formatting helpers.
+ *
+ * Built on top of next-intl's format API, which wraps Intl.DateTimeFormat /
+ * Intl.RelativeTimeFormat using the active request locale. These helpers add
+ * three things on top of raw next-intl:
+ *
+ * 1. Tolerant input — accepts string | Date | null | undefined and returns
+ * a stable em-dash for missing values, so call sites don't need to
+ * conditionally render.
+ * 2. Two presets used everywhere in the portal (`dateTime`, `dateOnly`)
+ * so the four locales render consistently. German/French/Italian use
+ * 24h DD.MM.YYYY HH:mm; English uses 12h MMM D, YYYY h:mm a.
+ * 3. A `relative` helper that auto-picks the right unit (minute/hour/day/
+ * week/month) based on the elapsed delta.
+ *
+ * Usage in client components:
+ *
+ * import { useFormatter } from "next-intl";
+ * import { formatDateTime, formatRelative } from "@/lib/format";
+ *
+ * const f = useFormatter();
+ * {formatDateTime(req.createdAt, f)}
+ *
+ * {formatRelative(req.createdAt, f)}
+ *
+ *
+ * Usage in server components:
+ *
+ * import { getFormatter } from "next-intl/server";
+ * const f = await getFormatter();
+ * ...same calls...
+ */
+
+// next-intl's `useFormatter()` (client) and `getFormatter()` (server) return
+// the same shape. We derive the type from useFormatter's return so we stay
+// in sync with next-intl version bumps without hand-maintaining a mirror.
+import type { useFormatter } from "next-intl";
+type Formatter = ReturnType;
+
+const FALLBACK = "—";
+
+function toDate(value: string | Date | null | undefined): Date | null {
+ if (!value) return null;
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+/**
+ * Full date+time, locale-formatted. Returns "—" if the value is missing.
+ *
+ * de: 25.04.2026, 14:30
+ * en: Apr 25, 2026, 2:30 PM
+ * fr: 25 avr. 2026, 14:30
+ * it: 25 apr 2026, 14:30
+ */
+export function formatDateTime(
+ value: string | Date | null | undefined,
+ formatter: Formatter
+): string {
+ const d = toDate(value);
+ if (!d) return FALLBACK;
+ return formatter.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/**
+ * Date only, locale-formatted. Use in dense table cells.
+ */
+export function formatDateOnly(
+ value: string | Date | null | undefined,
+ formatter: Formatter
+): string {
+ const d = toDate(value);
+ if (!d) return FALLBACK;
+ return formatter.dateTime(d, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+/**
+ * Relative time ("2 hours ago", "vor 2 Stunden", etc.).
+ * Picks the unit automatically based on the magnitude of the delta.
+ * Returns "—" if the value is missing.
+ *
+ * Anchors against `now` (defaults to current time) so SSR and client
+ * render the same string when called within a single request.
+ */
+export function formatRelative(
+ value: string | Date | null | undefined,
+ formatter: Formatter,
+ now: Date = new Date()
+): string {
+ const d = toDate(value);
+ if (!d) return FALLBACK;
+
+ const diffMs = d.getTime() - now.getTime();
+ const absSeconds = Math.abs(diffMs) / 1000;
+
+ let unit: Intl.RelativeTimeFormatUnit;
+ if (absSeconds < 60) unit = "second";
+ else if (absSeconds < 3_600) unit = "minute";
+ else if (absSeconds < 86_400) unit = "hour";
+ else if (absSeconds < 604_800) unit = "day";
+ else if (absSeconds < 2_592_000) unit = "week";
+ else if (absSeconds < 31_536_000) unit = "month";
+ else unit = "year";
+
+ return formatter.relativeTime(d, { now, unit });
+}
diff --git a/src/messages/de.json b/src/messages/de.json
index d60ba4e..07fa7e2 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -34,7 +34,8 @@
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen",
"successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.",
- "goToLogin": "Zur Anmeldung"
+ "goToLogin": "Zur Anmeldung",
+ "duplicateDomain": "Für die E-Mail-Domain {domain} ist bereits ein Konto registriert. Bitte wenden Sie sich an Ihren Firmenadministrator, um eingeladen zu werden, oder kontaktieren Sie den PieCed-IT-Support, falls dies ein Fehler ist."
},
"onboarding": {
"loading": "Status wird geladen…",
@@ -81,7 +82,8 @@
"phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
- "goToDashboard": "Zum Dashboard"
+ "goToDashboard": "Zum Dashboard",
+ "submittedAt": "Eingereicht"
},
"dashboard": {
"title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pakete",
"workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.",
- "usage": "Nutzung & Kosten"
+ "usage": "Nutzung & Kosten",
+ "provisioned": "Bereitgestellt"
},
"usage": {
"inputTokens": "Input-Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent",
"status": "Status",
"submitted": "Eingereicht",
+ "updated": "Aktualisiert",
"actions": "Aktionen",
"noRequests": "Keine Anfragen gefunden.",
"loadingRequests": "Anfragen werden geladen…",
diff --git a/src/messages/en.json b/src/messages/en.json
index a7d8fe6..e1cfd36 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -34,7 +34,8 @@
"footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received",
"successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.",
- "goToLogin": "Go to Sign In"
+ "goToLogin": "Go to Sign In",
+ "duplicateDomain": "An account for the email domain {domain} is already registered. Please contact your company administrator to be invited, or reach out to PieCed IT support if you believe this is in error."
},
"onboarding": {
"loading": "Loading status…",
@@ -81,7 +82,8 @@
"phase": "Phase",
"readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
- "goToDashboard": "Go to Dashboard"
+ "goToDashboard": "Go to Dashboard",
+ "submittedAt": "Submitted"
},
"dashboard": {
"title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Packages",
"workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.",
- "usage": "Usage & Spend"
+ "usage": "Usage & Spend",
+ "provisioned": "Provisioned"
},
"usage": {
"inputTokens": "Input Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent",
"status": "Status",
"submitted": "Submitted",
+ "updated": "Updated",
"actions": "Actions",
"noRequests": "No requests found.",
"loadingRequests": "Loading requests…",
diff --git a/src/messages/fr.json b/src/messages/fr.json
index de58763..428ed52 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -34,7 +34,8 @@
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue",
"successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Ensuite, vous pourrez vous connecter et configurer votre assistant IA.",
- "goToLogin": "Aller à la connexion"
+ "goToLogin": "Aller à la connexion",
+ "duplicateDomain": "Un compte pour le domaine de courriel {domain} est déjà enregistré. Veuillez contacter l'administrateur de votre entreprise pour être invité, ou contactez le support PieCed IT si vous pensez qu'il s'agit d'une erreur."
},
"onboarding": {
"loading": "Chargement du statut…",
@@ -81,7 +82,8 @@
"phase": "Phase",
"readyTitle": "Votre assistant est prêt !",
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
- "goToDashboard": "Aller au tableau de bord"
+ "goToDashboard": "Aller au tableau de bord",
+ "submittedAt": "Soumis"
},
"dashboard": {
"title": "Tableau de bord",
@@ -99,7 +101,8 @@
"packages": "Paquets",
"workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.",
- "usage": "Utilisation et coûts"
+ "usage": "Utilisation et coûts",
+ "provisioned": "Provisionné"
},
"usage": {
"inputTokens": "Tokens d'entrée",
@@ -191,6 +194,7 @@
"agentName": "Agent",
"status": "Statut",
"submitted": "Soumis",
+ "updated": "Mis à jour",
"actions": "Actions",
"noRequests": "Aucune demande trouvée.",
"loadingRequests": "Chargement des demandes…",
diff --git a/src/messages/it.json b/src/messages/it.json
index 9cb87a0..7072327 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -34,7 +34,8 @@
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta",
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.",
- "goToLogin": "Vai all'accesso"
+ "goToLogin": "Vai all'accesso",
+ "duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore."
},
"onboarding": {
"loading": "Caricamento stato…",
@@ -81,7 +82,8 @@
"phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.",
- "goToDashboard": "Vai alla dashboard"
+ "goToDashboard": "Vai alla dashboard",
+ "submittedAt": "Inviato"
},
"dashboard": {
"title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pacchetti",
"workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.",
- "usage": "Utilizzo e costi"
+ "usage": "Utilizzo e costi",
+ "provisioned": "Attivato"
},
"usage": {
"inputTokens": "Token di input",
@@ -191,6 +194,7 @@
"agentName": "Agente",
"status": "Stato",
"submitted": "Inviato",
+ "updated": "Aggiornato",
"actions": "Azioni",
"noRequests": "Nessuna richiesta trovata.",
"loadingRequests": "Caricamento richieste…",