Timestamp and registration checking

This commit is contained in:
2026-04-25 18:09:02 +02:00
parent f550b3400f
commit b9654d7a7c
13 changed files with 525 additions and 25 deletions

View File

@@ -238,6 +238,17 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
);
}
/**
* 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.

266
src/lib/domain-check.ts Normal file
View File

@@ -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<string> = 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 '%@<domain>' is anchored so 'acme.ch' does not
* match 'notacme.ch' or 'acme.ch.evil.com'.
*/
async function findDuplicateInDb(
pool: Pool,
domain: string
): Promise<boolean> {
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<boolean> {
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<DuplicateCheckResult> {
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 };
}

118
src/lib/format.ts Normal file
View File

@@ -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();
* <span>{formatDateTime(req.createdAt, f)}</span>
* <span title={formatDateTime(req.createdAt, f)}>
* {formatRelative(req.createdAt, f)}
* </span>
*
* 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<typeof useFormatter>;
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 });
}