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

@@ -1,5 +1,5 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { getTenantRequestByOrgId } from "@/lib/db"; import { getTenantRequestByOrgId } from "@/lib/db";
@@ -7,6 +7,7 @@ import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { formatDateTime } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
export default async function DashboardPage() { export default async function DashboardPage() {
@@ -15,6 +16,7 @@ export default async function DashboardPage() {
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tAdmin = await getTranslations("admin"); const tAdmin = await getTranslations("admin");
const f = await getFormatter();
const allTenants = await listTenants(); const allTenants = await listTenants();
@@ -110,9 +112,7 @@ export default async function DashboardPage() {
{tenant.spec.packages?.join(", ") || "—"} {tenant.spec.packages?.join(", ") || "—"}
</td> </td>
<td className="px-5 py-3 text-xs text-text-muted tabular-nums"> <td className="px-5 py-3 text-xs text-text-muted tabular-nums">
{tenant.metadata.creationTimestamp {formatDateTime(tenant.metadata.creationTimestamp, f)}
? new Date(tenant.metadata.creationTimestamp).toLocaleDateString()
: "—"}
</td> </td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<Link <Link

View File

@@ -44,6 +44,12 @@ export default function RegisterPage() {
if (!res.ok) { if (!res.ok) {
const data = await res.json(); 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"); throw new Error(data.error || "Registration failed");
} }

View File

@@ -1,5 +1,5 @@
import { getSessionUser } from "@/lib/session"; 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 { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { StatusBadge } from "@/components/ui/status-badge"; 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 { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users"; import { ChannelUsers } from "@/components/channel-users/channel-users";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -20,6 +21,7 @@ export default async function TenantDetailPage({
const { name } = await params; const { name } = await params;
const t = await getTranslations("tenantDetail"); const t = await getTranslations("tenantDetail");
const f = await getFormatter();
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) notFound(); if (!tenant) notFound();
@@ -60,6 +62,18 @@ export default async function TenantDetailPage({
{t("agent")}: {tenant.spec.agentName} {t("agent")}: {tenant.spec.agentName}
</p> </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> </div>
{/* Usage */} {/* Usage */}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel"; import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import type { RegistrationInput } from "@/types"; import type { RegistrationInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
@@ -53,6 +54,28 @@ export async function POST(request: NextRequest) {
const input: RegistrationInput = parsed.data; 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({ const result = await registerCustomer({
companyName: input.companyName, companyName: input.companyName,
email: input.email, email: input.email,

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types"; import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
type Tab = "requests" | "tenants" | "health"; type Tab = "requests" | "tenants" | "health";
@@ -24,6 +25,7 @@ interface AdminPanelProps {
export function AdminPanel({ initialTenants }: AdminPanelProps) { export function AdminPanel({ initialTenants }: AdminPanelProps) {
const t = useTranslations("admin"); const t = useTranslations("admin");
const f = useFormatter();
const [tab, setTab] = useState<Tab>("requests"); const [tab, setTab] = useState<Tab>("requests");
// Requests state // Requests state
@@ -369,7 +371,19 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<RequestStatusBadge status={req.status} /> <RequestStatusBadge status={req.status} />
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
@@ -536,11 +550,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
: "—"} : "—"}
</td> </td>
<td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell"> <td className="px-4 py-3 text-xs text-text-muted tabular-nums hidden md:table-cell">
{tenant.metadata.creationTimestamp <div
? new Date( title={formatDateTime(
tenant.metadata.creationTimestamp tenant.metadata.creationTimestamp,
).toLocaleDateString() 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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format";
interface OnboardingState { interface OnboardingState {
state: string; state: string;
@@ -13,6 +14,7 @@ interface OnboardingState {
companyName: string; companyName: string;
agentName: string; agentName: string;
adminNotes?: string; adminNotes?: string;
createdAt?: string;
}; };
tenant?: { tenant?: {
name: string; name: string;
@@ -30,6 +32,7 @@ interface OnboardingState {
export function ProvisioningStatus() { export function ProvisioningStatus() {
const t = useTranslations("onboarding"); const t = useTranslations("onboarding");
const f = useFormatter();
const [data, setData] = useState<OnboardingState | null>(null); const [data, setData] = useState<OnboardingState | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -107,6 +110,20 @@ export function ProvisioningStatus() {
<p className="text-sm text-text-secondary max-w-sm mx-auto"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")} {t("pendingDescription")}
</p> </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> </div>
</Card> </Card>
); );

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. * Mark a tenant request as "deleted" when the associated tenant CR is deleted.
* This allows the customer to re-submit the onboarding wizard. * 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 });
}

View File

@@ -34,7 +34,8 @@
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.", "footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen", "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.", "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": { "onboarding": {
"loading": "Status wird geladen…", "loading": "Status wird geladen…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!", "readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pakete", "packages": "Pakete",
"workspaceFiles": "Workspace-Dateien", "workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.", "notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten" "usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt"
}, },
"usage": { "usage": {
"inputTokens": "Input-Tokens", "inputTokens": "Input-Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Eingereicht", "submitted": "Eingereicht",
"updated": "Aktualisiert",
"actions": "Aktionen", "actions": "Aktionen",
"noRequests": "Keine Anfragen gefunden.", "noRequests": "Keine Anfragen gefunden.",
"loadingRequests": "Anfragen werden geladen…", "loadingRequests": "Anfragen werden geladen…",

View File

@@ -34,7 +34,8 @@
"footer": "Your data is hosted exclusively on-premises in Switzerland.", "footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received", "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.", "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": { "onboarding": {
"loading": "Loading status…", "loading": "Loading status…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Your assistant is ready!", "readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Packages", "packages": "Packages",
"workspaceFiles": "Workspace Files", "workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.", "notFound": "Tenant not found.",
"usage": "Usage & Spend" "usage": "Usage & Spend",
"provisioned": "Provisioned"
}, },
"usage": { "usage": {
"inputTokens": "Input Tokens", "inputTokens": "Input Tokens",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Status", "status": "Status",
"submitted": "Submitted", "submitted": "Submitted",
"updated": "Updated",
"actions": "Actions", "actions": "Actions",
"noRequests": "No requests found.", "noRequests": "No requests found.",
"loadingRequests": "Loading requests…", "loadingRequests": "Loading requests…",

View File

@@ -34,7 +34,8 @@
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.", "footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue", "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.", "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": { "onboarding": {
"loading": "Chargement du statut…", "loading": "Chargement du statut…",
@@ -81,7 +82,8 @@
"phase": "Phase", "phase": "Phase",
"readyTitle": "Votre assistant est prêt !", "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.", "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": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -99,7 +101,8 @@
"packages": "Paquets", "packages": "Paquets",
"workspaceFiles": "Fichiers workspace", "workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.", "notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts" "usage": "Utilisation et coûts",
"provisioned": "Provisionné"
}, },
"usage": { "usage": {
"inputTokens": "Tokens d'entrée", "inputTokens": "Tokens d'entrée",
@@ -191,6 +194,7 @@
"agentName": "Agent", "agentName": "Agent",
"status": "Statut", "status": "Statut",
"submitted": "Soumis", "submitted": "Soumis",
"updated": "Mis à jour",
"actions": "Actions", "actions": "Actions",
"noRequests": "Aucune demande trouvée.", "noRequests": "Aucune demande trouvée.",
"loadingRequests": "Chargement des demandes…", "loadingRequests": "Chargement des demandes…",

View File

@@ -34,7 +34,8 @@
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.", "footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta", "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.", "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": { "onboarding": {
"loading": "Caricamento stato…", "loading": "Caricamento stato…",
@@ -81,7 +82,8 @@
"phase": "Fase", "phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!", "readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.", "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -99,7 +101,8 @@
"packages": "Pacchetti", "packages": "Pacchetti",
"workspaceFiles": "File workspace", "workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.", "notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi" "usage": "Utilizzo e costi",
"provisioned": "Attivato"
}, },
"usage": { "usage": {
"inputTokens": "Token di input", "inputTokens": "Token di input",
@@ -191,6 +194,7 @@
"agentName": "Agente", "agentName": "Agente",
"status": "Stato", "status": "Stato",
"submitted": "Inviato", "submitted": "Inviato",
"updated": "Aggiornato",
"actions": "Azioni", "actions": "Azioni",
"noRequests": "Nessuna richiesta trovata.", "noRequests": "Nessuna richiesta trovata.",
"loadingRequests": "Caricamento richieste…", "loadingRequests": "Caricamento richieste…",