Timestamp and registration checking
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { getTenantRequestByOrgId } from "@/lib/db";
|
||||
@@ -7,6 +7,7 @@ import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@@ -15,6 +16,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tAdmin = await getTranslations("admin");
|
||||
const f = await getFormatter();
|
||||
|
||||
const allTenants = await listTenants();
|
||||
|
||||
@@ -110,9 +112,7 @@ export default async function DashboardPage() {
|
||||
{tenant.spec.packages?.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-text-muted tabular-nums">
|
||||
{tenant.metadata.creationTimestamp
|
||||
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
|
||||
: "—"}
|
||||
{formatDateTime(tenant.metadata.creationTimestamp, f)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<Link
|
||||
|
||||
@@ -44,6 +44,12 @@ export default function RegisterPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
// Localize known structured codes; fall back to server-supplied
|
||||
// English message for everything else (validation, ZITADEL errors,
|
||||
// generic 500s).
|
||||
if (data.code === "duplicate_domain" && data.domain) {
|
||||
throw new Error(t("duplicateDomain", { domain: data.domain }));
|
||||
}
|
||||
throw new Error(data.error || "Registration failed");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
@@ -7,6 +7,7 @@ import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { PackageList } from "@/components/packages/package-list";
|
||||
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
|
||||
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
|
||||
|
||||
@@ -20,6 +21,7 @@ export default async function TenantDetailPage({
|
||||
|
||||
const { name } = await params;
|
||||
const t = await getTranslations("tenantDetail");
|
||||
const f = await getFormatter();
|
||||
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) notFound();
|
||||
@@ -60,6 +62,18 @@ export default async function TenantDetailPage({
|
||||
{t("agent")}: {tenant.spec.agentName}
|
||||
</p>
|
||||
)}
|
||||
{tenant.metadata.creationTimestamp && (
|
||||
<p
|
||||
className="text-xs text-text-muted mt-1"
|
||||
title={formatDateTime(tenant.metadata.creationTimestamp, f)}
|
||||
>
|
||||
{t("provisioned")}{" "}
|
||||
{formatRelative(tenant.metadata.creationTimestamp, f)}{" "}
|
||||
<span className="text-text-muted/60">
|
||||
({formatDateTime(tenant.metadata.creationTimestamp, f)})
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { registerCustomer } from "@/lib/zitadel";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { checkDuplicateDomain } from "@/lib/db";
|
||||
import type { RegistrationInput } from "@/types";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -53,6 +54,28 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const input: RegistrationInput = parsed.data;
|
||||
|
||||
// --- Duplicate-domain check ---
|
||||
//
|
||||
// Block if another active tenant_request or ZITADEL org already exists
|
||||
// for this corporate email domain. Public domains (gmail, gmx, etc.)
|
||||
// are exempted by checkDuplicateDomain.
|
||||
//
|
||||
// We return a structured `code: "duplicate_domain"` with the matched
|
||||
// domain so the client can render the localized message via
|
||||
// register.duplicateDomain (with {domain} interpolation). The fallback
|
||||
// English string is included for non-i18n clients (curl, monitoring).
|
||||
const dup = await checkDuplicateDomain(input.email);
|
||||
if (dup.blocked && dup.domain) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`,
|
||||
code: "duplicate_domain",
|
||||
domain: dup.domain,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await registerCustomer({
|
||||
companyName: input.companyName,
|
||||
email: input.email,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
type Tab = "requests" | "tenants" | "health";
|
||||
@@ -24,6 +25,7 @@ interface AdminPanelProps {
|
||||
|
||||
export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
const t = useTranslations("admin");
|
||||
const f = useFormatter();
|
||||
const [tab, setTab] = useState<Tab>("requests");
|
||||
|
||||
// Requests state
|
||||
@@ -369,7 +371,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<RequestStatusBadge status={req.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{new Date(req.createdAt).toLocaleDateString()}
|
||||
<div
|
||||
title={`${t("submitted")}: ${formatDateTime(req.createdAt, f)}${
|
||||
req.updatedAt && req.updatedAt !== req.createdAt
|
||||
? `\n${t("updated")}: ${formatDateTime(req.updatedAt, f)}`
|
||||
: ""
|
||||
}`}
|
||||
className="leading-tight"
|
||||
>
|
||||
<div>{formatDateTime(req.createdAt, f)}</div>
|
||||
<div className="text-[10px] text-text-muted/70">
|
||||
{formatRelative(req.createdAt, f)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -536,11 +550,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
|
||||
{tenant.metadata.creationTimestamp
|
||||
? new Date(
|
||||
tenant.metadata.creationTimestamp
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
<div
|
||||
title={formatDateTime(
|
||||
tenant.metadata.creationTimestamp,
|
||||
f
|
||||
)}
|
||||
className="leading-tight"
|
||||
>
|
||||
<div>
|
||||
{formatDateTime(
|
||||
tenant.metadata.creationTimestamp,
|
||||
f
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-muted/70">
|
||||
{formatRelative(
|
||||
tenant.metadata.creationTimestamp,
|
||||
f
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
|
||||
interface OnboardingState {
|
||||
state: string;
|
||||
@@ -13,6 +14,7 @@ interface OnboardingState {
|
||||
companyName: string;
|
||||
agentName: string;
|
||||
adminNotes?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
tenant?: {
|
||||
name: string;
|
||||
@@ -30,6 +32,7 @@ interface OnboardingState {
|
||||
|
||||
export function ProvisioningStatus() {
|
||||
const t = useTranslations("onboarding");
|
||||
const f = useFormatter();
|
||||
const [data, setData] = useState<OnboardingState | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -107,6 +110,20 @@ export function ProvisioningStatus() {
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{t("pendingDescription")}
|
||||
</p>
|
||||
{data.request?.createdAt && (
|
||||
<p
|
||||
className="text-xs text-text-muted mt-4"
|
||||
title={formatDateTime(data.request.createdAt, f)}
|
||||
>
|
||||
{t("submittedAt")}{" "}
|
||||
<span className="text-text-secondary">
|
||||
{formatRelative(data.request.createdAt, f)}
|
||||
</span>{" "}
|
||||
<span className="text-text-muted/60">
|
||||
({formatDateTime(data.request.createdAt, f)})
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user