From b9654d7a7c1512c04545b35ccdbe381f0a73073c Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 25 Apr 2026 18:09:02 +0200 Subject: [PATCH] Timestamp and registration checking --- src/app/[locale]/dashboard/page.tsx | 8 +- src/app/[locale]/register/page.tsx | 6 + src/app/[locale]/tenants/[name]/page.tsx | 16 +- src/app/api/register/route.ts | 23 ++ src/components/admin/admin-panel.tsx | 43 ++- .../onboarding/provisioning-status.tsx | 19 +- src/lib/db.ts | 11 + src/lib/domain-check.ts | 266 ++++++++++++++++++ src/lib/format.ts | 118 ++++++++ src/messages/de.json | 10 +- src/messages/en.json | 10 +- src/messages/fr.json | 10 +- src/messages/it.json | 10 +- 13 files changed, 525 insertions(+), 25 deletions(-) create mode 100644 src/lib/domain-check.ts create mode 100644 src/lib/format.ts diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index 78ba33a..79fef48 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -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(", ") || "—"} - {tenant.metadata.creationTimestamp - ? new Date(tenant.metadata.creationTimestamp).toLocaleDateString() - : "—"} + {formatDateTime(tenant.metadata.creationTimestamp, f)} )} + {tenant.metadata.creationTimestamp && ( +

+ {t("provisioned")}{" "} + {formatRelative(tenant.metadata.creationTimestamp, f)}{" "} + + ({formatDateTime(tenant.metadata.creationTimestamp, f)}) + +

+ )} {/* Usage */} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index e02fea1..aa34420 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -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, diff --git a/src/components/admin/admin-panel.tsx b/src/components/admin/admin-panel.tsx index a1c0eb7..75a1e9e 100644 --- a/src/components/admin/admin-panel.tsx +++ b/src/components/admin/admin-panel.tsx @@ -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("requests"); // Requests state @@ -369,7 +371,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { - {new Date(req.createdAt).toLocaleDateString()} +
+
{formatDateTime(req.createdAt, f)}
+
+ {formatRelative(req.createdAt, f)} +
+
@@ -536,11 +550,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) { : "—"} - {tenant.metadata.creationTimestamp - ? new Date( - tenant.metadata.creationTimestamp - ).toLocaleDateString() - : "—"} +
+
+ {formatDateTime( + tenant.metadata.creationTimestamp, + f + )} +
+
+ {formatRelative( + tenant.metadata.creationTimestamp, + f + )} +
+
diff --git a/src/components/onboarding/provisioning-status.tsx b/src/components/onboarding/provisioning-status.tsx index 9c8589d..940dc21 100644 --- a/src/components/onboarding/provisioning-status.tsx +++ b/src/components/onboarding/provisioning-status.tsx @@ -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(null); const [error, setError] = useState(""); @@ -107,6 +110,20 @@ export function ProvisioningStatus() {

{t("pendingDescription")}

+ {data.request?.createdAt && ( +

+ {t("submittedAt")}{" "} + + {formatRelative(data.request.createdAt, f)} + {" "} + + ({formatDateTime(data.request.createdAt, f)}) + +

+ )}
); 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…",