Timestamp and registration checking
This commit is contained in:
@@ -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
266
src/lib/domain-check.ts
Normal 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
118
src/lib/format.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user