@@ -107,7 +111,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps<"/admin
className="block max-w-[260px] truncate font-medium outline-none after:absolute after:inset-0 focus-visible:underline"
>
{u.name || u.email}
- {u.id === me.id && (you) }
+ {u.id === me.id && ({it ? "tu" : "you"}) }
{u.name && {u.email}
}
@@ -117,17 +121,17 @@ export default async function AdminUsersPage({ searchParams }: PageProps<"/admin
{u.isSuperAdmin ? (
- Super admin
+ {it ? "Super amministratore" : "Super admin"}
) : (
- User
+ {it ? "Utente" : "User"}
)}
- {u.status === "active" ? "Active" : "Disabled"}
+ {u.status === "active" ? (it ? "Attivo" : "Active") : (it ? "Disattivato" : "Disabled")}
-
+
{formatNumber(u.sessions)}
diff --git a/src/app/admin/workspaces/[id]/page.tsx b/src/app/admin/workspaces/[id]/page.tsx
index ab2c7f5..7e72097 100644
--- a/src/app/admin/workspaces/[id]/page.tsx
+++ b/src/app/admin/workspaces/[id]/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next"
+import { pageTitle } from "@/server/locale"
import Link from "next/link"
import { notFound } from "next/navigation"
import { AlertTriangle, ArrowUpRight, Inbox, MessagesSquare, Users } from "lucide-react"
@@ -27,6 +28,7 @@ import {
subscriptionTone,
} from "@/components/admin/workspaces/labels"
import { WorkspaceInitial } from "@/components/admin/workspaces/workspace-initial"
+import { localizeRoleName } from "@/lib/i18n"
import {
DeleteWorkspaceControl,
SubscriptionControls,
@@ -38,11 +40,13 @@ export async function generateMetadata({ params }: PageProps<"/admin/workspaces/
await requireSuperAdminPage()
const { id } = await params
const detail = await getWorkspaceDetail(id)
- return { title: detail ? `${detail.org.name} · Workspaces` : "Workspace" }
+ const section = await pageTitle("Workspaces", "Workspace")
+ return { title: detail ? `${detail.org.name} · ${section}` : await pageTitle("Workspace", "Workspace") }
}
export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/admin/workspaces/[id]">) {
const { user: me } = await requireSuperAdminPage()
+ const it = me.locale === "it"
const { id } = await params
const detail = await getWorkspaceDetail(id)
if (!detail) notFound()
@@ -62,14 +66,14 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
openAction = (
- Open workspace
+ {it ? "Apri workspace" : "Open workspace"}
)
} else if (owner && !owner.isSuperAdmin && owner.userStatus === "active") {
openAction = (
- Open as owner
+ {it ? "Apri come proprietario" : "Open as owner"}
)
}
@@ -77,22 +81,22 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
return (
<>
- {planLabel(org.plan)}
+ {it ? ({ free: "Gratuito", cloud: "Cloud", self_hosted: "Installato in autonomia", comped: "Gratuito" }[org.plan] ?? planLabel(org.plan)) : planLabel(org.plan)}
{org.suspendedAt ? (
- Suspended
+ {it ? "Sospeso" : "Suspended"}
) : (
{subscriptionLabel(org.subscriptionStatus)}
)}
- · created
+ · {it ? "creato" : "created"}
}
@@ -112,57 +116,57 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
- Suspended — the workspace is read-only.
+ {it ? "Sospeso" : "Suspended"} — {it ? "il workspace è in sola lettura." : "the workspace is read-only."}
-
{org.suspendedReason || "No reason given."}
+
{org.suspendedReason || (it ? "Nessun motivo indicato." : "No reason given.")}
)}
-
+
-
+
-
+
{inboxPath} },
{
- label: "Owner",
+ label: it ? "Proprietario" : "Owner",
value: owner ? (
{owner.name ? `${owner.name} · ${owner.email}` : owner.email}
) : (
- No owner
+ {it ? "Nessun proprietario" : "No owner"}
),
},
{
- label: "Created",
+ label: it ? "Creato" : "Created",
value: (
<>
{creator && (
<>
{" "}
- by{" "}
+ {it ? "da" : "by"}{" "}
{creator.email}
@@ -172,28 +176,28 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
),
},
{
- label: "Onboarding",
+ label: it ? "Configurazione iniziale" : "Onboarding",
value: org.onboardingCompletedAt ? (
<>
- Completed
+ {it ? "Completato" : "Completed"}
>
) : (
- "Not completed"
+ it ? "Non completato" : "Not completed"
),
},
{
- label: "Last activity",
- value: ,
+ label: it ? "Ultima attività" : "Last activity",
+ value: ,
},
- { label: "Teams", value: formatNumber(stats.teams) },
- { label: "Time zone", value: org.settings?.timezone || "Instance default" },
+ { label: it ? "Team" : "Teams", value: formatNumber(stats.teams) },
+ { label: it ? "Fuso orario" : "Time zone", value: org.settings?.timezone || (it ? "Predefinito istanza" : "Instance default") },
]}
/>
-
+
{members.length === 0 ? (
-
+
) : (
{members.map((m) => (
@@ -208,23 +212,23 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
{m.name || m.email}
{m.isSuperAdmin && (
- Super admin
+ {it ? "Super amministratore" : "Super admin"}
)}
{m.name ? m.email : null}
{m.name ? " · " : null}
- seen
+ {it ? "visto" : "seen"}
{(m.status !== "active" || m.userStatus !== "active") && (
- {m.userStatus !== "active" ? "Disabled" : "Suspended"}
+ {m.userStatus !== "active" ? (it ? "Disattivato" : "Disabled") : (it ? "Sospeso" : "Suspended")}
)}
- {m.roleName}
+ {localizeRoleName(m.roleKey, m.roleName, it ? "it" : "en")}
@@ -235,12 +239,12 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
{inboxes.length === 0 ? (
-
+
) : (
{inboxes.map((a) => (
@@ -250,17 +254,17 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
{a.name}
- {PROVIDER_LABELS[a.provider] ?? a.provider} · {a.personal ? "Personal" : "Shared"}
+ {PROVIDER_LABELS[a.provider] ?? a.provider} · {a.personal ? (it ? "Personale" : "Personal") : (it ? "Condivisa" : "Shared")}
{a.email}
- {a.status[0]!.toUpperCase() + a.status.slice(1)}
+ {it ? (({ connected: "Connessa", syncing: "In sincronizzazione", error: "Errore", paused: "In pausa", active: "Attiva", pending: "In attesa" } as Record)[a.status] ?? a.status) : a.status[0]!.toUpperCase() + a.status.slice(1)}
- synced
+ {it ? "sincronizzata" : "synced"}
@@ -277,7 +281,7 @@ export default async function AdminWorkspaceDetailPage({ params }: PageProps<"/a
-
+
-
+
-
+
- Danger zone
+ {it ? "Zona pericolosa" : "Danger zone"}
- Deleting removes all data of {org.name} permanently.
- {org.stripeSubscriptionId ? " Cancel the Stripe subscription separately." : ""}
+ {it ? "L’eliminazione rimuoverà definitivamente tutti i dati di " : "Deleting removes all data of "}{org.name} {it ? " in modo permanente." : " permanently."}
+ {org.stripeSubscriptionId ? (it ? " Annulla separatamente l’abbonamento Stripe." : " Cancel the Stripe subscription separately.") : ""}
diff --git a/src/app/admin/workspaces/page.tsx b/src/app/admin/workspaces/page.tsx
index d1dc2eb..9e8ae63 100644
--- a/src/app/admin/workspaces/page.tsx
+++ b/src/app/admin/workspaces/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import Link from "next/link"
import { Building2, ChevronRight } from "lucide-react"
@@ -16,14 +17,17 @@ import {
} from "@/components/admin/workspaces/labels"
import { WorkspaceInitial } from "@/components/admin/workspaces/workspace-initial"
-export const metadata: Metadata = { title: "Workspaces" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Workspaces", "Workspace") }
+}
function first(v: string | string[] | undefined) {
return Array.isArray(v) ? v[0] : v
}
export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/admin/workspaces">) {
- await requireSuperAdminPage()
+ const { user } = await requireSuperAdminPage()
+ const it = user.locale === "it"
const sp = await searchParams
const q = first(sp.q) ?? ""
const plan = first(sp.plan)
@@ -35,22 +39,22 @@ export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/
return (
<>
-
+
- ({ value: p.value, label: p.label }))} />
+ ({ value: p.value, label: it ? ({ Free: "Gratuito", Cloud: "Cloud", "Self-hosted": "Installato in autonomia", Comped: "Gratuito" }[p.label] ?? p.label) : p.label }))} />
s.value !== "incomplete").map((s) => ({ value: s.value, label: s.label })),
- { value: "suspended", label: "Suspended" },
+ ...SUBSCRIPTION_STATUS_OPTIONS.filter((s) => s.value !== "incomplete").map((s) => ({ value: s.value, label: it ? (({ Active: "Attivo", Trial: "Prova", Trialing: "In prova", None: "Nessuno", "Past due": "Pagamento in ritardo", Canceled: "Annullato", Unpaid: "Non pagato", "No subscription": "Nessun abbonamento" } as Record)[s.label] ?? s.label) : s.label })),
+ { value: "suspended", label: it ? "Sospeso" : "Suspended" },
]}
/>
@@ -60,9 +64,9 @@ export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/
@@ -86,30 +90,30 @@ export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/
- {planLabel(w.plan)}
+ {it ? ({ free: "Gratuito", cloud: "Cloud", self_hosted: "Installato in autonomia", comped: "Gratuito" }[w.plan] ?? planLabel(w.plan)) : planLabel(w.plan)}
{w.suspendedAt ? (
- Suspended
+ {it ? "Sospeso" : "Suspended"}
) : (
w.subscriptionStatus !== "none" && (
{subscriptionLabel(w.subscriptionStatus)}
)
)}
- {w.inboxErrors > 0 && {w.inboxErrors} inbox errors }
+ {w.inboxErrors > 0 && {w.inboxErrors} {it ? "errori caselle" : "inbox errors"} }
- {formatNumber(w.members)} members
+ {formatNumber(w.members)} {it ? "membri" : "members"}
- {formatNumber(w.inboxes)} inboxes
+ {formatNumber(w.inboxes)} {it ? "caselle" : "inboxes"}
- {formatNumber(w.conversations)} convos
+ {formatNumber(w.conversations)} {it ? "conversazioni" : "convos"}
- {w.ownerEmail ?? "No owner"} · created
+ {w.ownerEmail ?? (it ? "Nessun proprietario" : "No owner")} · {it ? "creato" : "created"}
@@ -121,14 +125,14 @@ export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/
Workspace
- Owner
- Members
- Inboxes
- Convos
- Plan
- Subscription
- Trial ends
- Created
+ {it ? "Proprietario" : "Owner"}
+ {it ? "Membri" : "Members"}
+ {it ? "Caselle" : "Inboxes"}
+ {it ? "Conversazioni" : "Convos"}
+ {it ? "Piano" : "Plan"}
+ {it ? "Abbonamento" : "Subscription"}
+ {it ? "Fine prova" : "Trial ends"}
+ {it ? "Creato" : "Created"}
@@ -163,12 +167,12 @@ export default async function AdminWorkspacesPage({ searchParams }: PageProps<"/
{formatNumber(w.conversations)}
- {planLabel(w.plan)}
+ {it ? ({ free: "Gratuito", cloud: "Cloud", self_hosted: "Installato in autonomia", comped: "Gratuito" }[w.plan] ?? planLabel(w.plan)) : planLabel(w.plan)}
{w.suspendedAt ? (
- Suspended
+ {it ? "Sospeso" : "Suspended"}
) : (
{subscriptionLabel(w.subscriptionStatus)}
)}
diff --git a/src/app/api/w/[slug]/attachments/[id]/route.ts b/src/app/api/w/[slug]/attachments/[id]/route.ts
index e98e0b7..ae9e22d 100644
--- a/src/app/api/w/[slug]/attachments/[id]/route.ts
+++ b/src/app/api/w/[slug]/attachments/[id]/route.ts
@@ -1,7 +1,8 @@
import { ApiError, route } from "@/server/api"
import { assertUuid, inboxContext } from "@/server/conversations/context"
import { INLINE_TYPES, getAuthorizedAttachment } from "@/server/conversations/files"
-import { getObject } from "@/server/storage"
+import { readAttachmentBuffer } from "@/server/mail/remote-attachments"
+import { getObject, REMOTE_ATTACHMENT_PREFIX } from "@/server/storage"
/**
* GET /api/w/[slug]/attachments/[id][?inline=1] — stream an attachment.
@@ -12,7 +13,9 @@ export const GET = route<{ slug: string; id: string }>(async (req, { params }) =
const { slug, id } = await params
const { ctx, scope } = await inboxContext(req, slug)
const att = await getAuthorizedAttachment(ctx, scope, assertUuid(id, "Attachment"))
- const body = await getObject(att.storageKey)
+ const remote = att.storageKey.startsWith(REMOTE_ATTACHMENT_PREFIX)
+ const remoteContent = remote ? await readAttachmentBuffer(att) : null
+ const body = remote ? (remoteContent ? Uint8Array.from(remoteContent) : null) : await getObject(att.storageKey)
if (!body) throw new ApiError(404, "The file is no longer available", "not_found")
const inline = req.nextUrl.searchParams.get("inline") === "1" && INLINE_TYPES.has(att.contentType)
diff --git a/src/app/api/w/[slug]/calendar/invitations/[attachmentId]/route.ts b/src/app/api/w/[slug]/calendar/invitations/[attachmentId]/route.ts
new file mode 100644
index 0000000..cebc081
--- /dev/null
+++ b/src/app/api/w/[slug]/calendar/invitations/[attachmentId]/route.ts
@@ -0,0 +1,25 @@
+import { z } from "zod"
+import { ApiError, json, parseJson, requireApiOrg, route } from "@/server/api"
+import { assertPermission, assertWritable } from "@/server/authz"
+import { assertUuid } from "@/server/conversations/context"
+import { invitation, responseSchema } from "@/server/calendar/invitations"
+
+type Params = { slug: string; attachmentId: string }
+
+export const GET = route(async (req, { params }) => {
+ const { slug, attachmentId } = await params
+ const ctx = await requireApiOrg(req, slug)
+ if (ctx.via !== "session") throw new ApiError(403, "Calendar requires a workspace session")
+ assertPermission(ctx, "calendar.use")
+ return json(await invitation(ctx, assertUuid(attachmentId)))
+})
+
+export const POST = route(async (req, { params }) => {
+ const { slug, attachmentId } = await params
+ const ctx = await requireApiOrg(req, slug)
+ if (ctx.via !== "session") throw new ApiError(403, "Calendar requires a workspace session")
+ assertPermission(ctx, "calendar.use")
+ assertWritable(ctx)
+ const { response } = await parseJson(req, z.object({ response: responseSchema }))
+ return json(await invitation(ctx, assertUuid(attachmentId), response))
+})
diff --git a/src/app/api/w/[slug]/calendar/route.ts b/src/app/api/w/[slug]/calendar/route.ts
new file mode 100644
index 0000000..065dbc6
--- /dev/null
+++ b/src/app/api/w/[slug]/calendar/route.ts
@@ -0,0 +1,20 @@
+import { ApiError, json, parseJson, parseQuery, requireApiOrg, route } from "@/server/api"
+import { assertPermission, assertWritable } from "@/server/authz"
+import { calendarAction, calendarQuery, getCalendar, mutateCalendar } from "@/server/calendar/service"
+
+type Params = { slug: string }
+
+export const GET = route(async (req, { params }) => {
+ const ctx = await requireApiOrg(req, (await params).slug)
+ if (ctx.via !== "session") throw new ApiError(403, "Calendar requires a workspace session")
+ assertPermission(ctx, "calendar.use")
+ return json(await getCalendar(ctx, parseQuery(req, calendarQuery)))
+})
+
+export const POST = route(async (req, { params }) => {
+ const ctx = await requireApiOrg(req, (await params).slug)
+ if (ctx.via !== "session") throw new ApiError(403, "Calendar requires a workspace session")
+ assertPermission(ctx, "calendar.use")
+ assertWritable(ctx)
+ return json(await mutateCalendar(ctx, await parseJson(req, calendarAction)))
+})
diff --git a/src/app/api/w/[slug]/drafts/[id]/route.ts b/src/app/api/w/[slug]/drafts/[id]/route.ts
index 662e249..6bbf288 100644
--- a/src/app/api/w/[slug]/drafts/[id]/route.ts
+++ b/src/app/api/w/[slug]/drafts/[id]/route.ts
@@ -1,6 +1,10 @@
-import { json, parseJson, route } from "@/server/api"
+import { after } from "next/server"
+import { appendArchiveDraft } from "@/server/mail/archive"
+import { rateLimit } from "@/server/rate-limit"
+import { z } from "zod"
+import { ApiError, json, parseJson, route } from "@/server/api"
import { assertUuid, inboxContext } from "@/server/conversations/context"
-import { discardDraft, draftSchema, getDraft, saveDraft } from "@/server/conversations/messages"
+import { buildDraftCopy, discardDraft, draftSchema, getDraft, readLinkedDraftEdit, saveDraft } from "@/server/conversations/messages"
type P = { slug: string; id: string }
@@ -20,7 +24,21 @@ export const PUT = route(async (req, { params }) => {
const { slug, id } = await params
const { ctx, scope } = await inboxContext(req, slug, { write: true })
const input = await parseJson(req, draftSchema)
- return json(await saveDraft(ctx, scope, assertUuid(id, "Draft"), input))
+ const saved = await saveDraft(ctx, scope, assertUuid(id, "Draft"), input)
+ if (saved.syncToMailServer && saved.authorId === ctx.user.id) {
+ after(async () => {
+ try {
+ const latest = await getDraft(ctx, scope, saved.id)
+ if (!latest.syncToMailServer || latest.accountId !== saved.accountId) return
+ const { account, source, copyId } = await buildDraftCopy(ctx, scope, latest.id, latest.version)
+ await appendArchiveDraft(account, source, copyId, { id: latest.id, version: latest.version })
+ } catch (err) {
+ if (err instanceof ApiError && [404, 409].includes(err.status)) return
+ console.error("[draft-sync] could not copy saved draft", err)
+ }
+ })
+ }
+ return json(saved)
})
/** DELETE /api/w/[slug]/drafts/[id] → 204 (discard) */
@@ -30,3 +48,23 @@ export const DELETE = route
(async (req, { params }) => {
await discardDraft(ctx, scope, assertUuid(id, "Draft"))
return new Response(null, { status: 204 })
})
+
+export const POST = route
(async (req, { params }) => {
+ const { slug, id } = await params
+ const { ctx, scope } = await inboxContext(req, slug, { write: true })
+ const { version } = await parseJson(req, z.object({ version: z.number().int().min(1) }))
+ const limit = await rateLimit(`draft-copy:${ctx.org.id}:${ctx.user.id}`, 8, 60)
+ if (!limit.ok) throw new ApiError(429, "Too many draft copies. Try again shortly.", "rate_limited")
+ const { account, source, copyId } = await buildDraftCopy(ctx, scope, assertUuid(id, "Draft"), version)
+ await appendArchiveDraft(account, source, copyId, { id: assertUuid(id, "Draft"), version })
+ return new Response(null, { status: 204 })
+})
+
+export const PATCH = route
(async (req, { params }) => {
+ const { slug, id } = await params
+ const { ctx, scope } = await inboxContext(req, slug, { write: true })
+ const limit = await rateLimit(`draft-pull:${ctx.org.id}:${ctx.user.id}`, 15, 60)
+ if (!limit.ok) throw new ApiError(429, "Too many draft checks. Try again shortly.", "rate_limited")
+ const imported = await readLinkedDraftEdit(ctx, scope, assertUuid(id, "Draft"))
+ return json({ draftId: imported?.id ?? null }, { headers: { "Cache-Control": "private, no-store" } })
+})
diff --git a/src/app/api/w/[slug]/mail-archive/route.ts b/src/app/api/w/[slug]/mail-archive/route.ts
new file mode 100644
index 0000000..8b13a1f
--- /dev/null
+++ b/src/app/api/w/[slug]/mail-archive/route.ts
@@ -0,0 +1,205 @@
+import { and, eq, gt, inArray, isNotNull, or, sql } from "drizzle-orm"
+import { z } from "zod"
+import { ApiError, json, parseJson, parseQuery, route } from "@/server/api"
+import { audit } from "@/server/audit"
+import { compactMessageAttachments, compactMessageHtml, importRemoteDraft } from "@/server/conversations/messages"
+import { markRead } from "@/server/conversations/mutations"
+import { inboxContext } from "@/server/conversations/context"
+import { atLeast, loadOneAccessible, visibleWhere, type Ctx, type InboxScope } from "@/server/conversations/scope"
+import { db, schema } from "@/server/db"
+import { importArchiveMessage, readArchive, readArchiveDraft, updateArchiveMessage } from "@/server/mail/archive"
+import { REMOTE_ATTACHMENT_PREFIX } from "@/server/storage"
+import { publish } from "@/server/realtime"
+import { rateLimit } from "@/server/rate-limit"
+
+const uid = z.coerce.number().int().min(1).max(4294967295)
+const folder = z.string().min(1).max(1024).regex(/^[^\x00-\x1f\x7f]+$/)
+const validity = z.string().regex(/^\d{1,20}$/)
+const query = z.object({
+ account: z.string().uuid(),
+ folder: folder.optional(),
+ validity: validity.optional(),
+ uid: uid.optional(),
+ before: uid.optional(),
+ part: z.string().max(64).regex(/^\d+(\.\d+)*$/).optional(),
+}).refine((value) =>
+ (!(value.uid || value.before) || Boolean(value.folder && value.validity)) &&
+ (!value.part || Boolean(value.uid)) && !(value.uid && value.before)
+)
+
+export const GET = route<{ slug: string }>(async (req, { params }) => {
+ const { ctx, scope } = await inboxContext(req, (await params).slug)
+ const input = parseQuery(req, query)
+ const account = await getAccount(ctx, scope, input.account)
+ return readArchive(account, input, req.signal, async (messageId) => {
+ const m = schema.messages
+ const c = schema.conversations
+ const [match] = await db.select({ id: c.id }).from(m)
+ .innerJoin(c, eq(c.id, m.conversationId))
+ .where(and(eq(m.orgId, ctx.org.id), eq(m.accountId, account.id), eq(m.messageId, messageId),
+ inArray(m.status, ["received", "sent"]), visibleWhere(ctx, scope)))
+ .limit(1)
+ return match?.id ?? null
+ })
+})
+
+async function getAccount(ctx: Ctx, scope: InboxScope, id: string) {
+ if (!scope.access.has(id)) throw new ApiError(404, "Inbox not found", "not_found")
+ const account = await db.query.accounts.findFirst({
+ where: and(eq(schema.accounts.id, id), eq(schema.accounts.orgId, ctx.org.id)),
+ })
+ if (!account || account.provider === "demo") throw new ApiError(404, "Inbox not found", "not_found")
+ const limit = await rateLimit(`mail-archive:${ctx.org.id}:${ctx.user.id}`, 90, 60)
+ if (!limit.ok) throw new ApiError(429, "Too many requests. Try again shortly.", "rate_limited")
+ return account
+}
+
+const target = { account: z.string().uuid(), folder, validity, uid }
+const mutation = z.discriminatedUnion("action", [
+ z.object({ ...target, action: z.literal("seen"), seen: z.boolean() }),
+ z.object({ ...target, action: z.literal("move"), destination: folder }),
+ z.object({ ...target, action: z.literal("trash") }),
+])
+
+export const PATCH = route<{ slug: string }>(async (req, { params }) => {
+ const { ctx, scope } = await inboxContext(req, (await params).slug, { write: true })
+ const input = await parseJson(req, mutation)
+ const account = await getAccount(ctx, scope, input.account)
+ const level = scope.access.get(account.id)
+ if (!level || !atLeast(level, "reply")) throw new ApiError(403, "This mailbox is read only.", "forbidden")
+ const canTrash = ctx.permissions.has("conversations.delete")
+ if (input.action === "trash" && !canTrash) throw new ApiError(403, "You don't have permission to trash messages.", "forbidden")
+ const result = await updateArchiveMessage(account, input, canTrash)
+ try {
+ const m = schema.messages
+ const c = schema.conversations
+ if (result.messageId && "seen" in result && result.seen !== undefined) {
+ const seen = result.seen
+ const [match] = await db.select({ id: c.id }).from(m)
+ .innerJoin(c, eq(c.id, m.conversationId))
+ .where(and(eq(m.orgId, ctx.org.id), eq(m.accountId, account.id), eq(m.messageId, result.messageId),
+ inArray(m.status, ["received", "sent"]), seen ? eq(c.messageCount, 1) : undefined, visibleWhere(ctx, scope)))
+ .limit(1)
+ if (match) {
+ await db.transaction((tx) => markRead(ctx.org.id, ctx.user.id, [match.id], seen, tx))
+ await publish({ orgId: ctx.org.id, type: "conversation.updated", conversationId: match.id, userIds: [ctx.user.id] })
+ }
+ }
+ if (result.messageId && result.destination) {
+ const state = schema.mailboxSyncState
+ const [destinationState] = result.destinationValidity ? await db.select({ validity: state.uidValidity })
+ .from(state).where(and(eq(state.accountId, account.id), eq(state.mailbox, result.destination))).limit(1) : []
+ const destinationUid = destinationState && String(destinationState.validity) === result.destinationValidity
+ ? result.destinationUid : null
+ const destination = result.destination
+ const messageId = result.messageId
+ const trashState = result.trashState
+ const updated = await db.transaction(async (tx) => {
+ const [message] = await tx.update(m)
+ .set({ imapMailbox: destination, imapUid: destinationUid })
+ .where(and(eq(m.orgId, ctx.org.id), eq(m.accountId, account.id), eq(m.messageId, messageId),
+ eq(m.imapMailbox, result.source), eq(m.imapUid, input.uid), inArray(m.status, ["received", "sent"])))
+ .returning({ conversationId: m.conversationId })
+ if (message && trashState !== undefined) {
+ await tx.update(c).set({ isTrash: trashState })
+ .where(and(eq(c.orgId, ctx.org.id), eq(c.accountId, account.id), eq(c.id, message.conversationId), eq(c.messageCount, 1)))
+ }
+ return message
+ })
+ if (updated) await publish({ orgId: ctx.org.id, type: "conversation.updated", conversationId: updated.conversationId })
+ }
+ } catch (error) {
+ // The IMAP operation has already succeeded. Don't suggest retrying it after a local failure.
+ console.error("[mail-archive] failed to reconcile an IMAP change", error)
+ }
+ if (result.destination) {
+ await audit({
+ orgId: ctx.org.id,
+ actorId: ctx.user.id,
+ action: input.action === "trash" ? "mail.trashed" : "mail.moved",
+ targetType: "account",
+ targetId: account.id,
+ metadata: { folder: input.folder, uid: input.uid, validity: input.validity, destination: result.destination },
+ })
+ }
+ return new Response(null, { status: 204 })
+})
+
+export const POST = route<{ slug: string }>(async (req, { params }) => {
+ const { ctx, scope } = await inboxContext(req, (await params).slug, { write: true })
+ const input = await parseJson(req, z.object({ ...target, asDraft: z.boolean().optional() }))
+ const account = await getAccount(ctx, scope, input.account)
+ const level = scope.access.get(account.id)
+ if (!level || !atLeast(level, "reply") || !ctx.permissions.has("conversations.reply")) {
+ throw new ApiError(403, "You don't have permission to import into this mailbox.", "forbidden")
+ }
+ const limit = await rateLimit(`mail-archive-import:${ctx.org.id}:${ctx.user.id}`, 8, 60)
+ if (!limit.ok) throw new ApiError(429, "Too many imports. Try again shortly.", "rate_limited")
+ if (input.asDraft) {
+ const { mail: remote, origin } = await readArchiveDraft(account, input)
+ const saved = await importRemoteDraft(ctx, scope, account.id, remote, origin)
+ return json({ draftId: saved.id }, { headers: { "Cache-Control": "private, no-store" } })
+ }
+ const result = await importArchiveMessage(account, input)
+ if (result.status === "created") {
+ await publish({
+ orgId: ctx.org.id,
+ type: result.conversationCreated ? "conversation.created" : "conversation.updated",
+ conversationId: result.conversationId,
+ data: { accountId: account.id },
+ })
+ await audit({
+ orgId: ctx.org.id,
+ actorId: ctx.user.id,
+ action: "mail.imported",
+ targetType: "conversation",
+ targetId: result.conversationId,
+ metadata: { accountId: account.id, folder: input.folder, uid: input.uid, validity: input.validity },
+ })
+ }
+ if (!(await loadOneAccessible(ctx, scope, result.conversationId))) {
+ throw new ApiError(404, "Conversation not found", "not_found")
+ }
+ return json({ conversationId: result.conversationId }, { headers: { "Cache-Control": "private, no-store" } })
+})
+
+export const PUT = route<{ slug: string }>(async (req, { params }) => {
+ const { ctx, scope } = await inboxContext(req, (await params).slug, { write: true })
+ const input = await parseJson(req, z.object({ account: z.uuid(), after: z.uuid().nullable() }))
+ if (scope.access.get(input.account) !== "manage") {
+ throw new ApiError(403, "You can't manage this mailbox.", "forbidden")
+ }
+ const account = await getAccount(ctx, scope, input.account)
+ const m = schema.messages
+ const c = schema.conversations
+ const hasHtml = sql`(${m.htmlBody} is not null and nullif(btrim(${m.textBody}), '') is not null)`
+ const hasAttachments = sql`exists (
+ select 1 from attachments a where a.org_id = ${ctx.org.id}
+ and a.message_id = "messages"."id" and a.storage_key not like ${`${REMOTE_ATTACHMENT_PREFIX}%`}
+ )`
+ const [message] = await db.select({ id: m.id, hasHtml, hasAttachments }).from(m)
+ .innerJoin(c, eq(c.id, m.conversationId))
+ .where(and(eq(m.orgId, ctx.org.id), eq(m.accountId, account.id), eq(m.status, "received"),
+ isNotNull(m.imapMailbox), isNotNull(m.messageId), visibleWhere(ctx, scope),
+ input.after ? gt(m.id, input.after) : undefined, or(hasHtml, hasAttachments)))
+ .orderBy(m.id)
+ .limit(1)
+ if (!message) return json({ next: null, verified: 0, skipped: 0 })
+
+ let verified = 0
+ let skipped = 0
+ const operations = [
+ ...(message.hasHtml ? [compactMessageHtml] : []),
+ ...(message.hasAttachments ? [compactMessageAttachments] : []),
+ ]
+ for (const compact of operations) {
+ try {
+ await compact(ctx, scope, message.id)
+ verified++
+ } catch (err) {
+ if (!(err instanceof ApiError) || err.status === 403) throw err
+ skipped++
+ }
+ }
+ return json({ next: message.id, verified, skipped })
+})
diff --git a/src/app/api/w/[slug]/messages/[id]/route.ts b/src/app/api/w/[slug]/messages/[id]/route.ts
index d6b0d36..72dcf9f 100644
--- a/src/app/api/w/[slug]/messages/[id]/route.ts
+++ b/src/app/api/w/[slug]/messages/[id]/route.ts
@@ -1,6 +1,7 @@
-import { json, route } from "@/server/api"
+import { ApiError, json, route } from "@/server/api"
import { assertUuid, inboxContext } from "@/server/conversations/context"
-import { getMessageBody } from "@/server/conversations/messages"
+import { compactMessageAttachments, compactMessageHtml, getMessageBody } from "@/server/conversations/messages"
+import { rateLimit } from "@/server/rate-limit"
/**
* GET /api/w/[slug]/messages/[id]?images=1 → MessageBody { html, hasRemoteImages, imagesLoaded }
@@ -11,5 +12,16 @@ export const GET = route<{ slug: string; id: string }>(async (req, { params }) =
const { slug, id } = await params
const { ctx, scope } = await inboxContext(req, slug)
const body = await getMessageBody(ctx, scope, assertUuid(id, "Message"), req.nextUrl.searchParams.get("images") === "1")
- return json(body, { headers: { "Cache-Control": "private, max-age=300" } })
+ return json(body, { headers: { "Cache-Control": body.remoteHtmlUnavailable ? "private, no-store" : "private, max-age=300" } })
+})
+
+export const POST = route<{ slug: string; id: string }>(async (req, { params }) => {
+ const { slug, id } = await params
+ const { ctx, scope } = await inboxContext(req, slug, { write: true })
+ const limit = await rateLimit(`mail-release-html:${ctx.org.id}:${ctx.user.id}`, 10, 60)
+ if (!limit.ok) throw new ApiError(429, "Too many requests. Try again shortly.", "rate_limited")
+ const messageId = assertUuid(id, "Message")
+ if (req.nextUrl.searchParams.get("attachments") === "1") await compactMessageAttachments(ctx, scope, messageId)
+ else await compactMessageHtml(ctx, scope, messageId)
+ return new Response(null, { status: 204 })
})
diff --git a/src/app/auth/verify/page.tsx b/src/app/auth/verify/page.tsx
index 1459af0..b4795ad 100644
--- a/src/app/auth/verify/page.tsx
+++ b/src/app/auth/verify/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import Link from "next/link"
import { LinkIcon, MailX } from "lucide-react"
@@ -8,10 +9,12 @@ import { AuthCard, AuthHeading, CenteredAuthLayout } from "@/components/auth/aut
import { VerifyForm } from "@/components/auth/verify-form"
import { Button } from "@/components/ui/button"
-export const metadata: Metadata = {
- title: "Confirm sign-in",
- robots: { index: false, follow: false },
- referrer: "no-referrer",
+export async function generateMetadata(): Promise {
+ return {
+ title: await pageTitle("Confirm sign-in", "Conferma accesso"),
+ robots: { index: false, follow: false },
+ referrer: "no-referrer",
+ }
}
/**
@@ -28,6 +31,7 @@ export default async function VerifyPage({ searchParams }: PageProps<"/auth/veri
getCurrentSession(),
])
const productName = branding.productName || general.instanceName
+ const it = session?.user.locale === "it"
return (
@@ -38,25 +42,23 @@ export default async function VerifyPage({ searchParams }: PageProps<"/auth/veri
- Continue as {peek.email} . This link can only be used
- once.
+ {it ? "Continua come" : "Continue as"} {peek.email} . {it ? "Questo link può essere usato una sola volta." : "This link can only be used once."}
>
}
/>
{session && session.user.email.toLowerCase() !== peek.email && (
- You're currently signed in as {session.user.email} on this
- device. Continuing adds a session for {peek.email}.
+ {it ? "Hai già effettuato l’accesso come" : "You’re currently signed in as"} {session.user.email} {it ? "su questo dispositivo. Continuando aggiungerai una sessione per" : "on this device. Continuing adds a session for"} {peek.email}.
)}
- Didn't request this? You can close this page — nothing happens until you click continue.
+ {it ? "Non hai richiesto questo accesso? Puoi chiudere la pagina: non succede nulla finché non fai clic su Continua." : "Didn’t request this? You can close this page — nothing happens until you click continue."}
>
) : (
@@ -65,13 +67,13 @@ export default async function VerifyPage({ searchParams }: PageProps<"/auth/veri
- Request a new link
+ {it ? "Richiedi un nuovo link" : "Request a new link"}
>
)}
diff --git a/src/app/error.tsx b/src/app/error.tsx
index 9bea2d2..9e322bd 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -4,8 +4,11 @@ import { useEffect } from "react"
import Link from "next/link"
import { LogoMark } from "@/components/brand/logo"
import { Button } from "@/components/ui/button"
+import { useI18n } from "@/lib/i18n"
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
useEffect(() => {
console.error(error)
}, [error])
@@ -13,17 +16,17 @@ export default function ErrorPage({ error, reset }: { error: Error & { digest?:
return (
- Something went wrong
+ {it ? "Si è verificato un errore" : "Something went wrong"}
- That didn't work. Please try again.
+ {it ? "Operazione non riuscita." : "That didn’t work."} {it ? "Riprova." : "Please try again."}
{error.digest && Reference: {error.digest}
}
- Try again
+ {it ? "Riprova" : "Try again"}
- Back to my inbox
+ {it ? "Torna alla posta in arrivo" : "Back to my inbox"}
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
index da1a55f..b9abd27 100644
--- a/src/app/global-error.tsx
+++ b/src/app/global-error.tsx
@@ -1,8 +1,11 @@
"use client"
+import { useState } from "react"
+
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ const [italian] = useState(() => typeof document !== "undefined" && (document.cookie.split(";").some((cookie) => cookie.trim() === "dispatch-locale=it") || navigator.language.toLowerCase().startsWith("it")))
return (
-
+
-
Dispatch hit an unexpected error.
- {error.digest &&
Reference: {error.digest}
}
+
{italian ? "Dispatch ha riscontrato un errore imprevisto." : "Dispatch hit an unexpected error."}
+ {error.digest &&
{italian ? "Riferimento" : "Reference"}: {error.digest}
}
- Try again
+ {italian ? "Riprova" : "Try again"}
diff --git a/src/app/globals.css b/src/app/globals.css
index c44c0cf..63e3c49 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -192,6 +192,14 @@
* {
@apply border-border outline-ring/50;
}
+ @media (hover: none) and (pointer: coarse) {
+ input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]),
+ textarea,
+ select,
+ [contenteditable="true"] {
+ font-size: max(16px, 1em) !important;
+ }
+ }
html {
@apply font-sans;
-webkit-tap-highlight-color: transparent;
diff --git a/src/app/invite/[token]/actions.ts b/src/app/invite/[token]/actions.ts
index 2d11d38..0dfb423 100644
--- a/src/app/invite/[token]/actions.ts
+++ b/src/app/invite/[token]/actions.ts
@@ -1,7 +1,7 @@
"use server"
import { redirect, RedirectType } from "next/navigation"
-import { and, eq } from "drizzle-orm"
+import { and, eq, sql } from "drizzle-orm"
import { z } from "zod"
import { db, schema } from "@/server/db"
import { action } from "@/server/action"
@@ -12,8 +12,11 @@ import { clearSessionCookie } from "@/server/auth/session"
import { hashToken } from "@/server/crypto"
import { acceptInvitation } from "@/server/orgs"
import { getRequestMeta } from "@/server/request"
+import { PASSWORD_MIN_LENGTH, passwordHash } from "@/server/auth/password"
+import { establishSession } from "@/app/login/complete-login"
const tokenSchema = z.object({ token: z.string().min(20).max(200) })
+const passwordInviteSchema = tokenSchema.extend({ password: z.string().min(PASSWORD_MIN_LENGTH, `Use at least ${PASSWORD_MIN_LENGTH} characters`).max(4000) })
async function findInvitation(token: string) {
return db.query.invitations.findFirst({ where: eq(schema.invitations.tokenHash, hashToken(token)) })
@@ -47,6 +50,30 @@ export const acceptInviteAction = action(tokenSchema, async ({ token }) => {
redirect(`/w/${org.slug}/inbox`, RedirectType.replace)
})
+/** Create/set a local password directly from a valid invitation, then join and sign in. */
+export const setInvitePasswordAction = action(passwordInviteSchema, async ({ token, password }) => {
+ const inv = await findInvitation(token)
+ if (!inv || inv.revokedAt) throw new ApiError(404, "This invitation is no longer valid.")
+ if (inv.acceptedAt) throw new ApiError(410, "This invitation has already been used.")
+ if (inv.expiresAt < new Date()) throw new ApiError(410, "This invitation has expired. Ask for a new one.")
+ const email = inv.email.toLowerCase()
+ let user = await db.query.users.findFirst({ where: sql`lower(${schema.users.email}) = ${email}` })
+ if (!user) {
+ const [created] = await db.insert(schema.users).values({ email, name: null, passwordHash: passwordHash(password) }).returning()
+ user = created
+ } else {
+ const [updated] = await db.update(schema.users).set({ passwordHash: passwordHash(password), status: "active" }).where(eq(schema.users.id, user.id)).returning()
+ user = updated
+ }
+ if (!user) throw new ApiError(500, "Could not create the account.")
+ await acceptInvitation(inv, user.id)
+ const org = await db.query.organizations.findFirst({ where: eq(schema.organizations.id, inv.orgId) })
+ if (!org) throw new ApiError(404, "This workspace no longer exists.")
+ await db.update(schema.users).set({ preferences: { ...user.preferences, lastOrgSlug: org.slug } }).where(eq(schema.users.id, user.id))
+ const redirectTo = await establishSession({ userId: user.id, email: user.email, method: "password", redirectTo: `/w/${org.slug}/inbox` })
+ redirect(redirectTo, RedirectType.replace)
+})
+
/** Signed in with a different account: sign out this device and continue with the invited email. */
export const switchAccountAction = action(tokenSchema, async ({ token }) => {
const s = await requireUser()
diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx
index cfe8bd6..9669589 100644
--- a/src/app/invite/[token]/page.tsx
+++ b/src/app/invite/[token]/page.tsx
@@ -1,28 +1,37 @@
+import { pageTitle } from "@/server/locale"
/* eslint-disable @next/next/no-img-element -- workspace logos are user-provided URLs/uploads, not optimizable assets */
import type { Metadata } from "next"
import Link from "next/link"
import { redirect } from "next/navigation"
+import { cookies, headers } from "next/headers"
import { and, eq } from "drizzle-orm"
import { CalendarX2, MailX, ShieldOff } from "lucide-react"
import { db, schema } from "@/server/db"
import { hashToken } from "@/server/crypto"
import { getSettings } from "@/server/settings"
import { getCurrentSession } from "@/server/auth/session"
-import { AuthCard, AuthHeading, CenteredAuthLayout, ImpersonationBanner } from "@/components/auth/auth-layout"
+import { AuthCard, AuthHeading, CenteredAuthLayout } from "@/components/auth/auth-layout"
+import { ImpersonationBanner } from "@/components/auth/impersonation-banner"
import { InviteActions } from "@/components/auth/invite-actions"
import { UserAvatar } from "@/components/app/user-avatar"
import { Button } from "@/components/ui/button"
import { LocalTime } from "@/components/app/local-time"
+import { InvitePasswordForm } from "@/components/auth/invite-password-form"
-export const metadata: Metadata = {
- title: "Invitation",
- robots: { index: false, follow: false },
- referrer: "no-referrer",
+export async function generateMetadata(): Promise {
+ return {
+ title: await pageTitle("Invitation", "Invito"),
+ robots: { index: false, follow: false },
+ referrer: "no-referrer",
+ }
}
export default async function InvitePage({ params }: PageProps<"/invite/[token]">) {
const { token } = await params
const [general, branding, session] = await Promise.all([getSettings("general"), getSettings("branding"), getCurrentSession()])
+ const requestHeaders = await headers()
+ const preferredLocale = (await cookies()).get("dispatch-locale")?.value
+ const italian = session?.user.locale === "it" || (!session && (preferredLocale === "it" || (!preferredLocale && requestHeaders.get("accept-language")?.toLowerCase().startsWith("it"))))
const productName = branding.productName || general.instanceName
const rows =
@@ -56,25 +65,72 @@ export default async function InvitePage({ params }: PageProps<"/invite/[token]"
const banner = session?.session.impersonatorId ? : undefined
const homeHref = "/login"
+ const copy = italian
+ ? {
+ invitation: "Invito",
+ notFound: "Invito non trovato",
+ invalidLink: "Questo link di invito non è valido. Controlla di aver copiato il link completo oppure chiedi un nuovo invito.",
+ withdrawn: "Questo invito è stato ritirato",
+ revoked: (workspace: string) => `Un amministratore di ${workspace} lo ha revocato. Chiedi di essere invitato di nuovo.`,
+ alreadyUsed: "Questo invito è già stato utilizzato",
+ signInToOpen: (email: string, workspace: string) => `Accedi con ${email} per aprire ${workspace}.`,
+ expired: "Questo invito è scaduto",
+ expiredText: (workspace: string) => `Gli inviti sono validi per 14 giorni. Chiedi a un amministratore di ${workspace} di inviarne uno nuovo.`,
+ goWorkspace: "Vai al tuo workspace",
+ signIn: "Accedi",
+ join: "Unisciti a",
+ on: "su",
+ invited: "ha invitato",
+ collaborate: "a collaborare: caselle condivise, commenti interni e assegnazioni, tutto in un unico posto.",
+ existingPassword: "Hai già una password?",
+ signInInstead: "Accedi invece",
+ signedAs: "Hai effettuato l'accesso come",
+ butInvitation: "ma questo invito è per",
+ switchAccount: "Esci da questo dispositivo e continua con l'indirizzo invitato.",
+ validUntil: "Invito valido fino al",
+ }
+ : {
+ invitation: "Invitation",
+ notFound: "Invitation not found",
+ invalidLink: "This invitation link is invalid. Check that you copied the whole link, or ask for a new invitation.",
+ withdrawn: "This invitation was withdrawn",
+ revoked: (workspace: string) => `An admin of ${workspace} revoked it. Ask them to invite you again.`,
+ alreadyUsed: "This invitation was already used",
+ signInToOpen: (email: string, workspace: string) => `Sign in with ${email} to open ${workspace}.`,
+ expired: "This invitation has expired",
+ expiredText: (workspace: string) => `Invitations are valid for 14 days. Ask an admin of ${workspace} to send a new one.`,
+ goWorkspace: "Go to your workspace",
+ signIn: "Sign in",
+ join: "Join",
+ on: "on",
+ invited: "invited",
+ collaborate: "to collaborate: shared inboxes, internal comments and assignments — all in one place.",
+ existingPassword: "Already have a password?",
+ signInInstead: "Sign in instead",
+ signedAs: "You're signed in as",
+ butInvitation: "but this invitation is for",
+ switchAccount: "Sign out on this device and continue with the invited address.",
+ validUntil: "Invitation valid until",
+ }
if (!row || row.inv.revokedAt || row.inv.acceptedAt || row.inv.expiresAt < new Date()) {
const state = !row ? "missing" : row.inv.revokedAt ? "revoked" : row.inv.acceptedAt ? "accepted" : "expired"
- const copy = {
- missing: { icon: MailX, title: "Invitation not found", quiet: "", text: "This invitation link is invalid. Check that you copied the whole link, or ask for a new invitation." },
- revoked: { icon: ShieldOff, title: "This invitation was withdrawn", quiet: "", text: `An admin of ${row?.org.name ?? "the workspace"} revoked it. Ask them to invite you again.` },
- accepted: { icon: MailX, title: "This invitation was already used", quiet: "", text: `Sign in with ${row?.inv.email ?? "the invited address"} to open ${row?.org.name ?? "the workspace"}.` },
- expired: { icon: CalendarX2, title: "This invitation has expired", quiet: "", text: `Invitations are valid for 14 days. Ask an admin of ${row?.org.name ?? "the workspace"} to send a new one.` },
+ const stateCopy = {
+ missing: { icon: MailX, title: copy.notFound, quiet: "", text: copy.invalidLink },
+ revoked: { icon: ShieldOff, title: copy.withdrawn, quiet: "", text: copy.revoked(row?.org.name ?? (italian ? "workspace" : "the workspace")) },
+ accepted: { icon: MailX, title: copy.alreadyUsed, quiet: "", text: copy.signInToOpen(row?.inv.email ?? (italian ? "l'indirizzo invitato" : "the invited address"), row?.org.name ?? (italian ? "il workspace" : "the workspace")) },
+ expired: { icon: CalendarX2, title: copy.expired, quiet: "", text: copy.expiredText(row?.org.name ?? (italian ? "il workspace" : "the workspace")) },
}[state]
return (
-
+
-
+
- {session ? "Go to your workspace" : "Sign in"}
+ {session ? copy.goWorkspace : copy.signIn}
@@ -84,6 +140,7 @@ export default async function InvitePage({ params }: PageProps<"/invite/[token]"
const { inv, org, role, inviter } = row
const inviterName = inviter?.name || inviter?.email || "A teammate"
+ const translatedRole = italian ? ({ owner: "Proprietario", admin: "Amministratore", member: "Membro", guest: "Ospite" }[role.key ?? ""] ?? role.name) : role.name
const signedInAs = session?.user.email.toLowerCase()
const matches = signedInAs === inv.email.toLowerCase()
@@ -101,30 +158,25 @@ export default async function InvitePage({ params }: PageProps<"/invite/[token]"
)}
- {inviterName} invited{" "}
+ {inviterName} {copy.invited}{" "}
{inv.email} to collaborate as{" "}
- {role.name}
-
- . Shared inboxes, internal comments and assignments — all in one place.
+ {translatedRole}
+ {" "}{copy.collaborate}
>
}
/>
{!session ? (
<>
-
-
- Sign in to accept
-
-
+
- No password needed — we'll email a sign-in code to {inv.email}.
+ {copy.existingPassword}{" "} {copy.signInInstead}.
>
) : matches ? (
@@ -132,13 +184,13 @@ export default async function InvitePage({ params }: PageProps<"/invite/[token]"
) : (
<>
- You're signed in as {session.user.email} , but this invitation is for{" "}
- {inv.email} . Sign out on this device and continue with the invited address.
+ {copy.signedAs} {session.user.email} , {copy.butInvitation}{" "}
+ {inv.email} . {copy.switchAccount}
>
)}
- Invitation valid until .
+ {copy.validUntil} .
)
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index bf80d53..d2c06ee 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,11 @@
import type { Metadata, Viewport } from "next"
import { headers } from "next/headers"
+import { cookies } from "next/headers"
import { Geist, Geist_Mono } from "next/font/google"
import { Providers } from "@/components/providers"
import { getAppUrl } from "@/server/env"
+import { getCurrentSession } from "@/server/auth/session"
+import type { Locale } from "@/lib/i18n"
import "./globals.css"
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
@@ -37,10 +40,15 @@ export const viewport: Viewport = {
export default async function RootLayout({ children }: LayoutProps<"/">) {
// Per-request CSP nonce from src/proxy.ts (also makes every page dynamic, which nonces require)
const nonce = (await headers()).get("x-nonce") ?? undefined
+ const session = await getCurrentSession()
+ const requestHeaders = await headers()
+ const acceptLanguage = requestHeaders.get("accept-language") ?? ""
+ const preferredCookie = (await cookies()).get("dispatch-locale")?.value
+ const locale: Locale = session?.user.locale === "it" || (!session && (preferredCookie === "it" || (!preferredCookie && acceptLanguage.toLowerCase().startsWith("it")))) ? "it" : "en"
return (
-
+
- {children}
+ {children}
)
diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts
index 4573ece..9588eae 100644
--- a/src/app/login/actions.ts
+++ b/src/app/login/actions.ts
@@ -7,6 +7,7 @@ import { ApiError } from "@/server/api"
import { normalizeEmail, requestMagicLink, verifyLoginCode, verifyMagicToken } from "@/server/auth/magic-link"
import { getRequestMeta } from "@/server/request"
import { establishSession } from "./complete-login"
+import { authenticatePassword } from "@/server/auth/password"
const nextSchema = z.string().max(2000).optional()
@@ -21,6 +22,21 @@ export const requestLoginCodeAction = action(
}
)
+export const loginWithPasswordAction = action(
+ z.object({
+ email: z.string().trim().min(3).max(320),
+ password: z.string().min(1).max(4000),
+ next: nextSchema,
+ }),
+ async ({ email, password, next }) => {
+ const meta = await getRequestMeta()
+ const res = await authenticatePassword({ email, password, ip: meta.ip, userAgent: meta.userAgent })
+ if (!res.ok) throw new ApiError(401, res.error)
+ const redirectTo = await establishSession({ userId: res.userId, email: res.email, method: "password", redirectTo: next })
+ redirect(redirectTo, RedirectType.replace)
+ }
+)
+
/** Step 2: the 6-digit code from the email → session on this device, then navigate to the destination. */
export const verifyLoginCodeAction = action(
z.object({
diff --git a/src/app/login/complete-login.ts b/src/app/login/complete-login.ts
index 96f9cff..b96382e 100644
--- a/src/app/login/complete-login.ts
+++ b/src/app/login/complete-login.ts
@@ -15,7 +15,7 @@ import { getRequestMeta } from "@/server/request"
export async function establishSession(opts: {
userId: string
email: string
- method: "code" | "link" | "setup"
+ method: "code" | "link" | "password" | "setup"
isNewUser?: boolean
redirectTo?: string | null
}): Promise {
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 8b90732..1d3c3e5 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import Link from "next/link"
import { redirect } from "next/navigation"
@@ -7,12 +8,15 @@ import { safeRedirect } from "@/server/auth/magic-link"
import { resolveHomePath } from "@/server/orgs"
import { isEmailDeliveryConfigured } from "@/server/mail/system-mailer"
import { unsign } from "@/server/crypto"
+import { headers } from "next/headers"
import { SplitAuthLayout } from "@/components/auth/auth-layout"
import { LoginForm, type LoginNotice } from "@/components/auth/login-form"
-export const metadata: Metadata = {
- title: "Sign in",
- robots: { index: false, follow: false },
+export async function generateMetadata(): Promise {
+ return {
+ title: await pageTitle("Sign in", "Accedi"),
+ robots: { index: false, follow: false },
+ }
}
/**
@@ -35,12 +39,27 @@ const ERRORS: Record = {
session_expired: "Your session has expired. Please sign in again.",
}
+const ERRORS_IT: Record = {
+ access_denied: "Accesso annullato.",
+ oauth_denied: "Accesso annullato.",
+ oauth_failed: "Impossibile completare l’accesso con questo provider. Riprova oppure usa la tua email.",
+ oauth_state: "La sessione di accesso è scaduta. Riprova.",
+ oauth_disabled: "Questo metodo di accesso non è attivo in questa istanza.",
+ email_unverified: "Il provider non ha verificato il tuo indirizzo email. Accedi usando la tua email.",
+ not_allowed: "L’accesso a questa istanza è solo su invito. Chiedi a un amministratore del workspace di invitarti.",
+ domain: "Le registrazioni sono limitate ai domini email approvati.",
+ invite_only: "L’accesso a questa istanza è solo su invito. Chiedi a un amministratore del workspace di invitarti.",
+ disabled: "Questo account è stato disattivato. Contatta l’amministratore.",
+ session_expired: "La sessione è scaduta. Accedi di nuovo.",
+}
+
function first(v: string | string[] | undefined) {
return Array.isArray(v) ? v[0] : v
}
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
const sp = await searchParams
+ const italian = ((await headers()).get("accept-language") ?? "").toLowerCase().startsWith("it")
const next = safeRedirect(first(sp.next), "") || undefined
const status = await getSetupStatus()
@@ -64,16 +83,16 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
let notice: LoginNotice | null = null
if (errorCode) {
const signed = errorCode.length <= 600 ? unsign(errorCode, "login-error") : null
- notice = { tone: "error", text: signed?.slice(0, 300) || ERRORS[errorCode] || "Sign-in failed. Please try again." }
+ notice = { tone: "error", text: signed?.slice(0, 300) || (italian ? ERRORS_IT[errorCode] : ERRORS[errorCode]) || (italian ? "Accesso non riuscito. Riprova." : "Sign-in failed. Please try again.") }
}
- else if (first(sp.signed_out)) notice = { tone: "success", text: "You've been signed out on this device." }
+ else if (first(sp.signed_out)) notice = { tone: "success", text: italian ? "Hai effettuato la disconnessione su questo dispositivo." : "You've been signed out on this device." }
const signupHint =
auth.signupMode === "open"
- ? "New here? Enter your email — we'll create your account."
+ ? italian ? "È la prima volta? Inserisci la tua email: creeremo il tuo account." : "New here? Enter your email — we'll create your account."
: auth.signupMode === "domains"
- ? `New here? Sign up with your ${auth.allowedSignupDomains.map((d) => `@${d}`).join(", ")} email address.`
- : "New here? You'll need an invitation from a workspace admin."
+ ? italian ? `È la prima volta? Usa un indirizzo email ${auth.allowedSignupDomains.map((d) => `@${d}`).join(", ")}.` : `New here? Sign up with your ${auth.allowedSignupDomains.map((d) => `@${d}`).join(", ")} email address.`
+ : italian ? "È la prima volta? Ti serve un invito da un amministratore del workspace." : "New here? You'll need an invitation from a workspace admin."
const showLegal = Boolean(legal.terms.trim() || legal.privacy.trim())
const marketing = general.mode === "saas" && general.marketingSite
@@ -85,22 +104,22 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
footer={
showLegal ? (
<>
- By continuing you agree to the{" "}
+ {italian ? "Continuando accetti i " : "By continuing you agree to the "}
{legal.terms.trim() && (
- Terms
+ {italian ? "Termini" : "Terms"}
)}
{legal.terms.trim() && legal.privacy.trim() && " and "}
{legal.privacy.trim() && (
- Privacy Policy
+ {italian ? "Informativa privacy" : "Privacy Policy"}
)}
.
>
) : (
- <>Passwordless sign-in · sessions stay active for {auth.sessionDays >= 365 ? "a year" : `${auth.sessionDays} days`}>
+ <>{italian ? "Accesso con password o codice email · sessioni attive per " : "Sign in with a password or email code · sessions stay active for "}{auth.sessionDays >= 365 ? (italian ? "un anno" : "a year") : `${auth.sessionDays} ${italian ? "giorni" : "days"}`}>
)
}
>
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index c67b002..c2226ba 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,24 +1,28 @@
import Link from "next/link"
import { LogoMark } from "@/components/brand/logo"
import { Button } from "@/components/ui/button"
+import { cookies, headers } from "next/headers"
-export default function NotFound() {
+export default async function NotFound() {
+ const cookieStore = await cookies()
+ const requestHeaders = await headers()
+ const it = cookieStore.get("dispatch-locale")?.value === "it" || (!cookieStore.get("dispatch-locale") && requestHeaders.get("accept-language")?.toLowerCase().startsWith("it"))
return (
Error 404
- Lost in the inbox. This page doesn't exist.
+ {it ? "Ti sei perso nella posta." : "Lost in the inbox."} {it ? "Questa pagina non esiste." : "This page doesn’t exist."}
- The link may be broken, or you might not have access to this workspace.
+ {it ? "Il link potrebbe essere errato oppure potresti non avere accesso a questo workspace." : "The link may be broken, or you might not have access to this workspace."}
- Go to my inbox
+ {it ? "Vai alla posta in arrivo" : "Go to my inbox"}
- Home
+ {it ? "Home" : "Home"}
diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx
index 45f3b18..0244afb 100644
--- a/src/app/onboarding/page.tsx
+++ b/src/app/onboarding/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { Clock3, LogOut, MailPlus } from "lucide-react"
@@ -7,14 +8,17 @@ import { can, loadOrgContext, requireUserPage } from "@/server/authz"
import { hasUsedTrial, listPendingInvitations, listUserOrganizations } from "@/server/orgs"
import { canCreateWorkspace } from "@/server/setup"
import { isEmailDeliveryConfigured } from "@/server/mail/system-mailer"
-import { AuthCard, AuthHeading, CenteredAuthLayout, ImpersonationBanner } from "@/components/auth/auth-layout"
+import { AuthCard, AuthHeading, CenteredAuthLayout } from "@/components/auth/auth-layout"
+import { ImpersonationBanner } from "@/components/auth/impersonation-banner"
import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"
import { PendingInvitations } from "@/components/onboarding/pending-invitations"
import { formatMoney } from "@/components/admin/ui"
-export const metadata: Metadata = {
- title: "Create your workspace",
- robots: { index: false, follow: false },
+export async function generateMetadata(): Promise {
+ return {
+ title: await pageTitle("Create your workspace", "Crea il tuo workspace"),
+ robots: { index: false, follow: false },
+ }
}
/**
diff --git a/src/app/setup/actions.ts b/src/app/setup/actions.ts
index fe63e31..79a7e75 100644
--- a/src/app/setup/actions.ts
+++ b/src/app/setup/actions.ts
@@ -20,6 +20,7 @@ import {
verifySetupCode,
} from "@/server/setup"
import { establishSession } from "@/app/login/complete-login"
+import { PASSWORD_MIN_LENGTH } from "@/server/auth/password"
async function auditSetup(userId: string, email: string, actionName: string, metadata: Record = {}) {
const meta = await getRequestMeta()
@@ -42,8 +43,9 @@ export const createOwnerAction = action(
setupCode: z.string().max(40).optional(),
name: z.string().trim().min(1, "Enter your name").max(80),
email: emailSchema,
+ password: z.string().min(PASSWORD_MIN_LENGTH, `Use at least ${PASSWORD_MIN_LENGTH} characters`).max(4000),
}),
- async ({ setupCode, name, email }) => {
+ async ({ setupCode, name, email, password }) => {
const status = await getSetupStatus()
if (status.completed) throw new ApiError(403, "Setup has already been completed.")
if (status.hasSuperAdmin) throw new ApiError(409, "An instance owner already exists. Sign in to continue setup.")
@@ -58,7 +60,7 @@ export const createOwnerAction = action(
let user
try {
- user = await createFirstSuperAdmin({ name, email })
+ user = await createFirstSuperAdmin({ name, email, password })
} catch (err) {
if (err instanceof SetupError) throw new ApiError(409, err.message)
throw err
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index 9ff30f9..fec998b 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { ShieldCheck } from "lucide-react"
@@ -12,9 +13,11 @@ import { AuthCard, CenteredAuthLayout } from "@/components/auth/auth-layout"
import { LoginForm } from "@/components/auth/login-form"
import { SetupWizard } from "@/components/onboarding/setup-wizard"
-export const metadata: Metadata = {
- title: "Set up Dispatch",
- robots: { index: false, follow: false },
+export async function generateMetadata(): Promise {
+ return {
+ title: await pageTitle("Set up Dispatch", "Configura Dispatch"),
+ robots: { index: false, follow: false },
+ }
}
/**
diff --git a/src/app/w/[slug]/(mail)/[box]/[conversationId]/page.tsx b/src/app/w/[slug]/(mail)/[box]/[conversationId]/page.tsx
index 6bd9b92..92ce7a0 100644
--- a/src/app/w/[slug]/(mail)/[box]/[conversationId]/page.tsx
+++ b/src/app/w/[slug]/(mail)/[box]/[conversationId]/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { isUuid, parseBox, boxKey } from "@/lib/inbox/boxes"
import { ConversationView } from "@/components/inbox/conversation/conversation-view"
-export const metadata: Metadata = { title: "Conversation" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Conversation", "Conversazione") }
+}
export default async function ConversationPage({ params }: { params: Promise<{ box: string; conversationId: string }> }) {
const { box, conversationId } = await params
diff --git a/src/app/w/[slug]/(mail)/[box]/layout.tsx b/src/app/w/[slug]/(mail)/[box]/layout.tsx
index d10d31e..30fe965 100644
--- a/src/app/w/[slug]/(mail)/[box]/layout.tsx
+++ b/src/app/w/[slug]/(mail)/[box]/layout.tsx
@@ -1,11 +1,16 @@
-import { notFound } from "next/navigation"
+import { notFound, redirect } from "next/navigation"
+import { requireOrgPage } from "@/server/authz"
import { boxKey, parseBox } from "@/lib/inbox/boxes"
import { MailPanes } from "@/components/inbox/mail-panes"
/** List pane + conversation pane for a mailbox (`/w/[slug]/[box]`). */
export default async function BoxLayout({ children, params }: { children: React.ReactNode; params: Promise<{ slug: string; box: string }> }) {
- const { box } = await params
+ const { slug, box } = await params
const parsed = parseBox(box)
if (!parsed) notFound()
+ if (parsed.kind === "static" && parsed.id === "chats") {
+ const ctx = await requireOrgPage(slug)
+ if (ctx.org.settings.features?.chat === false) redirect(`/w/${slug}/inbox`)
+ }
return {children}
}
diff --git a/src/app/w/[slug]/(mail)/[box]/page.tsx b/src/app/w/[slug]/(mail)/[box]/page.tsx
index 8e8ec0b..72ca0a8 100644
--- a/src/app/w/[slug]/(mail)/[box]/page.tsx
+++ b/src/app/w/[slug]/(mail)/[box]/page.tsx
@@ -1,10 +1,36 @@
import type { Metadata } from "next"
+import { cookies, headers } from "next/headers"
import { parseBox, STATIC_BOX_META } from "@/lib/inbox/boxes"
+import { getCurrentSession } from "@/server/auth/session"
import { NoConversation } from "@/components/inbox/conversation/no-conversation"
+const BOX_TITLES_IT: Record = {
+ inbox: "Posta in arrivo",
+ assigned: "Assegnate a me",
+ mentions: "Menzioni",
+ starred: "Speciali",
+ snoozed: "Posticipate",
+ drafts: "Bozze",
+ scheduled: "Programmate",
+ sent: "Inviate",
+ unassigned: "Non assegnate",
+ all: "Tutte le conversazioni",
+ closed: "Chiuse",
+ spam: "Spam",
+ trash: "Cestino",
+ chats: "Chat",
+ search: "Ricerca avanzata",
+}
+
export async function generateMetadata({ params }: { params: Promise<{ box: string }> }): Promise {
const box = parseBox((await params).box)
- return { title: box?.kind === "static" ? STATIC_BOX_META[box.id].title : "Inbox" }
+ if (box?.kind !== "static") return { title: "Inbox" }
+ // Same locale resolution as the root layout, so the server-rendered tab title matches the UI language.
+ const session = await getCurrentSession()
+ const acceptLanguage = (await headers()).get("accept-language") ?? ""
+ const preferredCookie = (await cookies()).get("dispatch-locale")?.value
+ const it = session?.user.locale === "it" || (!session && (preferredCookie === "it" || (!preferredCookie && acceptLanguage.toLowerCase().startsWith("it"))))
+ return { title: it ? (BOX_TITLES_IT[box.id] ?? STATIC_BOX_META[box.id].title) : STATIC_BOX_META[box.id].title }
}
export default function BoxPage() {
diff --git a/src/app/w/[slug]/(mail)/mail-archive/page.tsx b/src/app/w/[slug]/(mail)/mail-archive/page.tsx
new file mode 100644
index 0000000..57fc702
--- /dev/null
+++ b/src/app/w/[slug]/(mail)/mail-archive/page.tsx
@@ -0,0 +1,5 @@
+import { MailArchive } from "@/components/inbox/mail-archive"
+
+export default function MailArchivePage() {
+ return
+}
diff --git a/src/app/w/[slug]/analytics/layout.tsx b/src/app/w/[slug]/analytics/layout.tsx
index fb048f8..dff49ed 100644
--- a/src/app/w/[slug]/analytics/layout.tsx
+++ b/src/app/w/[slug]/analytics/layout.tsx
@@ -1,8 +1,11 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { requireOrgPage, requirePagePermission } from "@/server/authz"
import { AnalyticsTopBar } from "@/components/analytics/analytics-top-bar"
-export const metadata: Metadata = { title: "Analytics" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Analytics", "Statistiche") }
+}
export default async function AnalyticsLayout({ children, params }: LayoutProps<"/w/[slug]/analytics">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/analytics/loading.tsx b/src/app/w/[slug]/analytics/loading.tsx
index 5998654..17f72a7 100644
--- a/src/app/w/[slug]/analytics/loading.tsx
+++ b/src/app/w/[slug]/analytics/loading.tsx
@@ -1,8 +1,12 @@
+"use client"
+
import { Skeleton } from "@/components/ui/skeleton"
+import { useI18n } from "@/lib/i18n"
export default function AnalyticsLoading() {
+ const { locale } = useI18n()
return (
-
+
diff --git a/src/app/w/[slug]/analytics/page.tsx b/src/app/w/[slug]/analytics/page.tsx
index 6a2a476..ba1df29 100644
--- a/src/app/w/[slug]/analytics/page.tsx
+++ b/src/app/w/[slug]/analytics/page.tsx
@@ -31,6 +31,7 @@ export default async function AnalyticsPage({ params, searchParams }: PageProps<
const { slug } = await params
const ctx = await requireOrgPage(slug)
requirePagePermission(ctx, "analytics.view")
+ const it = ctx.user.locale === "it"
const sp = await searchParams
const [tz, options, hasData] = await Promise.all([
@@ -69,12 +70,12 @@ export default async function AnalyticsPage({ params, searchParams }: PageProps<
return (
- Reports
+ {it ? "Report" : "Reports"}
- Analytics — how your team is doing
+ {it ? "Analisi" : "Analytics"} {it ? "— andamento del team" : "— how your team is doing"}
- Volume, responsiveness and workload across your shared inboxes. Times are shown in{" "}
+ {it ? "Volumi, tempi di risposta e carico di lavoro delle caselle condivise. Orari nel fuso " : "Volume, responsiveness and workload across your shared inboxes. Times are shown in "}
{tz.replace(/_/g, " ")} .
@@ -82,17 +83,17 @@ export default async function AnalyticsPage({ params, searchParams }: PageProps<
{!report ? (
}
- title={options.accounts.length === 0 ? "Connect a shared inbox to see analytics" : "No conversations yet"}
+ title={options.accounts.length === 0 ? (it ? "Collega una casella condivisa per vedere le analisi" : "Connect a shared inbox to see analytics") : (it ? "Nessuna conversazione" : "No conversations yet")}
description={
options.accounts.length === 0
- ? "Analytics cover your team's shared inboxes. Once a shared inbox is connected and emails arrive, you'll see volume, response times and workload here."
- : "As soon as emails arrive in your shared inboxes, you'll see volume, response times, busiest hours and per-teammate stats here."
+ ? (it ? "Le analisi riguardano le caselle condivise del team. Collega una casella e, all’arrivo delle email, vedrai qui volumi, tempi di risposta e carico di lavoro." : "Analytics cover your team's shared inboxes. Once a shared inbox is connected and emails arrive, you'll see volume, response times and workload here.")
+ : (it ? "Quando arrivano email nelle caselle condivise, qui vedrai volumi, tempi di risposta, fasce orarie più intense e statistiche per collega." : "As soon as emails arrive in your shared inboxes, you'll see volume, response times, busiest hours and per-teammate stats here.")
}
className="py-16"
>
{can(ctx, "inboxes.manage") && (
- {options.accounts.length === 0 ? "Connect an inbox" : "Manage inboxes"}
+ {options.accounts.length === 0 ? (it ? "Collega una casella" : "Connect an inbox") : (it ? "Gestisci caselle" : "Manage inboxes")}
)}
diff --git a/src/app/w/[slug]/calendar/page.tsx b/src/app/w/[slug]/calendar/page.tsx
new file mode 100644
index 0000000..bfa9134
--- /dev/null
+++ b/src/app/w/[slug]/calendar/page.tsx
@@ -0,0 +1,15 @@
+import { Suspense } from "react"
+import { requireOrgPage, requirePagePermission } from "@/server/authz"
+import { pageTitle } from "@/server/locale"
+import { CalendarView } from "@/components/calendar/calendar-view"
+
+export async function generateMetadata() {
+ return { title: await pageTitle("Calendar", "Calendario") }
+}
+
+export default async function CalendarPage({ params }: PageProps<"/w/[slug]/calendar">) {
+ const { slug } = await params
+ const ctx = await requireOrgPage(slug)
+ requirePagePermission(ctx, "calendar.use")
+ return
+}
diff --git a/src/app/w/[slug]/contacts/page.tsx b/src/app/w/[slug]/contacts/page.tsx
index e4807af..813f239 100644
--- a/src/app/w/[slug]/contacts/page.tsx
+++ b/src/app/w/[slug]/contacts/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { Suspense } from "react"
import { requireOrgPage } from "@/server/authz"
import { ContactsView } from "@/components/contacts/contacts-view"
-export const metadata: Metadata = { title: "Contacts" }
+export async function generateMetadata(): Promise
{
+ return { title: await pageTitle("Contacts", "Contatti") }
+}
export default async function ContactsPage({ params }: PageProps<"/w/[slug]/contacts">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/layout.tsx b/src/app/w/[slug]/layout.tsx
index 5c350aa..97c401a 100644
--- a/src/app/w/[slug]/layout.tsx
+++ b/src/app/w/[slug]/layout.tsx
@@ -37,6 +37,11 @@ export default async function WorkspaceLayout({ children, params }: LayoutProps<
plan: ctx.org.plan,
subscriptionStatus: ctx.org.subscriptionStatus,
trialEndsAt: ctx.org.trialEndsAt?.toISOString() ?? null,
+ features: { chat: ctx.org.settings.features?.chat !== false },
+ navigation: {
+ hiddenItems: ctx.org.settings.navigation?.hiddenItems ?? [],
+ hiddenSections: ctx.org.settings.navigation?.hiddenSections ?? [],
+ },
},
user: {
id: ctx.user.id,
@@ -54,24 +59,34 @@ export default async function WorkspaceLayout({ children, params }: LayoutProps<
workspaces: workspaces.map((w) => ({ id: w.id, name: w.name, slug: w.slug, logoUrl: w.logoUrl })),
productName: branding.productName || general.instanceName,
}
+ const it = ctx.user.locale === "it"
+ const lockedMessage = !ctx.locked ? null : it
+ ? ctx.locked.message === "Your free trial has ended. Subscribe to keep using this workspace."
+ ? "Il periodo di prova è terminato. Abbonati per continuare a usare questo workspace."
+ : ctx.locked.message === "This workspace has no active subscription. Subscribe to continue."
+ ? "Questo workspace non ha un abbonamento attivo. Abbonati per continuare."
+ : ctx.locked.message === "This workspace has been suspended by the instance administrator."
+ ? "Questo workspace è stato sospeso dall’amministratore dell’istanza."
+ : ctx.locked.message
+ : ctx.locked.message
return (
{value.impersonating && (
- You are impersonating {ctx.user.email}.
+ {it ? `Stai impersonando ${ctx.user.email}.` : `You are impersonating ${ctx.user.email}.`}
)}
{ctx.locked && (
- {ctx.locked.message} The workspace is read-only.
+ {lockedMessage} {it ? "Il workspace è in sola lettura." : "The workspace is read-only."}
{ctx.locked.reason === "billing" && ctx.permissions.has("billing.manage") && (
- Subscribe now
+ {it ? "Abbonati ora" : "Subscribe now"}
)}
diff --git a/src/app/w/[slug]/settings/audit-log/page.tsx b/src/app/w/[slug]/settings/audit-log/page.tsx
index 601e3c3..19115a5 100644
--- a/src/app/w/[slug]/settings/audit-log/page.tsx
+++ b/src/app/w/[slug]/settings/audit-log/page.tsx
@@ -1,10 +1,13 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { requireOrgPage, requirePagePermission } from "@/server/authz"
import { auditFilterSchema, listAuditFacets, listAuditLogs } from "@/server/workspace/queries/audit"
import { SettingsPage } from "@/components/settings/settings-ui"
import { AuditLog } from "@/components/settings/audit/audit-log"
-export const metadata: Metadata = { title: "Audit log" }
+export async function generateMetadata(): Promise
{
+ return { title: await pageTitle("Audit log", "Registro attività") }
+}
export default async function AuditLogPage({ params, searchParams }: PageProps<"/w/[slug]/settings/audit-log">) {
const { slug } = await params
@@ -24,10 +27,10 @@ export default async function AuditLogPage({ params, searchParams }: PageProps<"
return (
{
+ return { title: await pageTitle("Billing", "Fatturazione") }
+}
export default async function BillingPage({ params, searchParams }: PageProps<"/w/[slug]/settings/billing">) {
const { slug } = await params
@@ -16,19 +19,20 @@ export default async function BillingPage({ params, searchParams }: PageProps<"/
// Only known error codes are rendered — never free text from the URL
const code = typeof sp.error === "string" ? sp.error : null
- const error = code && Object.hasOwn(BILLING_ERRORS, code) ? BILLING_ERRORS[code as BillingErrorCode] : code ? "Something went wrong with billing. Please try again." : null
+ const it = ctx.user.locale === "it"
+ const error = code && Object.hasOwn(BILLING_ERRORS, code) ? BILLING_ERRORS[code as BillingErrorCode] : code ? (it ? "Si è verificato un errore con la fatturazione. Riprova." : "Something went wrong with billing. Please try again.") : null
const checkout = sp.checkout === "success" || sp.checkout === "canceled" ? sp.checkout : null
const now = new Date().getTime()
return (
diff --git a/src/app/w/[slug]/settings/general/page.tsx b/src/app/w/[slug]/settings/general/page.tsx
index 860ba18..7813060 100644
--- a/src/app/w/[slug]/settings/general/page.tsx
+++ b/src/app/w/[slug]/settings/general/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { isOwner, requirePagePermission, requireOrgPage } from "@/server/authz"
import { getSettings, readSecret } from "@/server/settings"
@@ -11,11 +12,15 @@ import { PUBLIC_EMAIL_DOMAINS, verifiedAdminDomains } from "@/server/workspace/s
import { SettingsPage } from "@/components/settings/settings-ui"
import { IdentitySection } from "@/components/settings/general/identity-section"
import { BusinessHoursSection, ConversationSection } from "@/components/settings/general/behaviour-sections"
+import { WorkspaceFeaturesSection } from "@/components/settings/general/features-section"
+import { WorkspaceNavigationSection } from "@/components/settings/general/navigation-section"
import { AccessSection } from "@/components/settings/general/access-section"
import { AiSection, type AiMode } from "@/components/settings/general/ai-section"
import { DangerZone, DemoDataSection } from "@/components/settings/general/danger-zone"
-export const metadata: Metadata = { title: "General" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("General", "Generale") }
+}
export default async function GeneralSettingsPage({ params }: PageProps<"/w/[slug]/settings/general">) {
const { slug } = await params
@@ -40,7 +45,7 @@ export default async function GeneralSettingsPage({ params }: PageProps<"/w/[slu
const defaultRoleId = selectableRoles.some((r) => r.id === s.defaultRoleId) ? s.defaultRoleId! : (memberRole?.id ?? selectableRoles[0]?.id ?? "")
return (
-
+
+
+
{
+ return { title: await pageTitle("Inbox settings", "Impostazioni casella") }
+}
export default async function InboxSettingsPage({ params }: PageProps<"/w/[slug]/settings/inboxes/[id]">) {
const { slug, id } = await params
diff --git a/src/app/w/[slug]/settings/inboxes/page.tsx b/src/app/w/[slug]/settings/inboxes/page.tsx
index 2dfd193..21bc409 100644
--- a/src/app/w/[slug]/settings/inboxes/page.tsx
+++ b/src/app/w/[slug]/settings/inboxes/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
@@ -9,7 +10,9 @@ import { ConnectInboxWizard } from "@/components/settings/inboxes/connect-wizard
import { ConnectResultBanner } from "@/components/settings/inboxes/connect-banner"
import { InboxList } from "@/components/settings/inboxes/inbox-list"
-export const metadata: Metadata = { title: "Inboxes" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Inboxes", "Caselle") }
+}
export default async function InboxesPage({ params, searchParams }: PageProps<"/w/[slug]/settings/inboxes">) {
const { slug } = await params
@@ -37,10 +40,10 @@ export default async function InboxesPage({ params, searchParams }: PageProps<"/
return (
0 ? : undefined}
>
{
+ return { title: await pageTitle("API & webhooks", "API e webhook") }
+}
export default async function IntegrationsPage({ params }: PageProps<"/w/[slug]/settings/integrations">) {
const { slug } = await params
@@ -21,10 +24,10 @@ export default async function IntegrationsPage({ params }: PageProps<"/w/[slug]/
return (
diff --git a/src/app/w/[slug]/settings/labels/page.tsx b/src/app/w/[slug]/settings/labels/page.tsx
index 5cbc050..81e7bea 100644
--- a/src/app/w/[slug]/settings/labels/page.tsx
+++ b/src/app/w/[slug]/settings/labels/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { can, requireOrgPage } from "@/server/authz"
import { loadLabelsPage } from "@/server/workspace/queries/labels"
import { LabelsSettings } from "@/components/settings/labels/labels-settings"
-export const metadata: Metadata = { title: "Labels" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Labels", "Etichette") }
+}
export default async function LabelsPage({ params }: PageProps<"/w/[slug]/settings/labels">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/layout.tsx b/src/app/w/[slug]/settings/layout.tsx
index 97008a4..4a445bf 100644
--- a/src/app/w/[slug]/settings/layout.tsx
+++ b/src/app/w/[slug]/settings/layout.tsx
@@ -1,8 +1,12 @@
import type { Metadata } from "next"
import { requireOrgPage } from "@/server/authz"
+import { requestLocale } from "@/server/locale"
import { SettingsShell } from "@/components/settings/settings-shell"
-export const metadata: Metadata = { title: { template: "%s · Settings", default: "Settings" } }
+export async function generateMetadata(): Promise {
+ const it = (await requestLocale()) === "it"
+ return { title: { template: it ? "%s · Impostazioni" : "%s · Settings", default: it ? "Impostazioni" : "Settings" } }
+}
export default async function SettingsLayout({ children, params }: LayoutProps<"/w/[slug]/settings">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/members/page.tsx b/src/app/w/[slug]/settings/members/page.tsx
index 84c81d2..8c7c5d6 100644
--- a/src/app/w/[slug]/settings/members/page.tsx
+++ b/src/app/w/[slug]/settings/members/page.tsx
@@ -1,10 +1,13 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { can, requireOrgPage } from "@/server/authz"
import { loadMembersPage } from "@/server/workspace/queries/members"
import { MembersSettings } from "@/components/settings/members/members-settings"
-export const metadata: Metadata = { title: "Members" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Members", "Membri") }
+}
export default async function MembersPage({ params }: PageProps<"/w/[slug]/settings/members">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/notifications/page.tsx b/src/app/w/[slug]/settings/notifications/page.tsx
index feed62d..32e482a 100644
--- a/src/app/w/[slug]/settings/notifications/page.tsx
+++ b/src/app/w/[slug]/settings/notifications/page.tsx
@@ -1,9 +1,12 @@
import type { Metadata } from "next"
import { requireOrgPage } from "@/server/authz"
+import { pageTitle } from "@/server/locale"
import { SettingsPage } from "@/components/settings/settings-ui"
import { NotificationsForm } from "@/components/settings/personal/notifications-form"
-export const metadata: Metadata = { title: "Notifications" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Notifications", "Notifiche") }
+}
export default async function NotificationsPage({ params }: PageProps<"/w/[slug]/settings/notifications">) {
const { slug } = await params
@@ -11,10 +14,10 @@ export default async function NotificationsPage({ params }: PageProps<"/w/[slug]
const n = ctx.user.preferences?.notifications ?? {}
return (
{
+ return { title: await pageTitle("Inbox settings", "Impostazioni casella") }
+}
export default async function PersonalInboxSettingsPage({ params }: PageProps<"/w/[slug]/settings/personal-inboxes/[id]">) {
const { slug, id } = await params
diff --git a/src/app/w/[slug]/settings/personal-inboxes/page.tsx b/src/app/w/[slug]/settings/personal-inboxes/page.tsx
index 39c4d71..20ee8c6 100644
--- a/src/app/w/[slug]/settings/personal-inboxes/page.tsx
+++ b/src/app/w/[slug]/settings/personal-inboxes/page.tsx
@@ -1,3 +1,4 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { requireOrgPage, requirePagePermission } from "@/server/authz"
import { getConnectOptions, listInboxes } from "@/server/workspace/queries/inboxes"
@@ -6,7 +7,9 @@ import { ConnectInboxWizard } from "@/components/settings/inboxes/connect-wizard
import { ConnectResultBanner } from "@/components/settings/inboxes/connect-banner"
import { InboxList } from "@/components/settings/inboxes/inbox-list"
-export const metadata: Metadata = { title: "My inboxes" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("My inboxes", "Le mie caselle") }
+}
export default async function PersonalInboxesPage({ params, searchParams }: PageProps<"/w/[slug]/settings/personal-inboxes">) {
const { slug } = await params
@@ -17,10 +20,10 @@ export default async function PersonalInboxesPage({ params, searchParams }: Page
return (
0 ? : undefined}
>
{
+ return { title: await pageTitle("Preferences", "Preferenze") }
+}
export default async function PreferencesPage({ params }: PageProps<"/w/[slug]/settings/preferences">) {
const { slug } = await params
@@ -12,7 +15,12 @@ export default async function PreferencesPage({ params }: PageProps<"/w/[slug]/s
const security = await getSettings("security")
const p = ctx.user.preferences ?? {}
return (
-
+
{
+ return { title: await pageTitle("Profile", "Profilo") }
+}
export default async function ProfilePage({ params }: PageProps<"/w/[slug]/settings/profile">) {
const { slug } = await params
@@ -14,41 +14,22 @@ export default async function ProfilePage({ params }: PageProps<"/w/[slug]/setti
const general = await getSettings("general")
const { user } = ctx
return (
-
- new Date()),
- }}
- />
-
-
- {ctx.org.name}
-
-
- {ctx.role.name}
-
-
- }
- description={
- <>
- Member since .
- >
- }
- >
-
-
-
-
+ new Date()),
+ }}
+ role={{ key: ctx.role.key, name: ctx.role.name, color: ctx.role.color }}
+ memberSince={ctx.membership.createdAt.toISOString()}
+ />
)
}
diff --git a/src/app/w/[slug]/settings/responses/page.tsx b/src/app/w/[slug]/settings/responses/page.tsx
index f2b24d5..d3e2024 100644
--- a/src/app/w/[slug]/settings/responses/page.tsx
+++ b/src/app/w/[slug]/settings/responses/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { can, requireOrgPage } from "@/server/authz"
import { loadResponsesPage } from "@/server/workspace/queries/responses"
import { ResponsesSettings } from "@/components/settings/responses/responses-settings"
-export const metadata: Metadata = { title: "Canned responses" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Canned responses", "Risposte predefinite") }
+}
export default async function ResponsesPage({ params }: PageProps<"/w/[slug]/settings/responses">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/roles/page.tsx b/src/app/w/[slug]/settings/roles/page.tsx
index 9cfffca..916b13e 100644
--- a/src/app/w/[slug]/settings/roles/page.tsx
+++ b/src/app/w/[slug]/settings/roles/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { requireOrgPage, requirePagePermission } from "@/server/authz"
import { loadRolesPage } from "@/server/workspace/queries/roles"
import { RolesSettings } from "@/components/settings/roles/roles-settings"
-export const metadata: Metadata = { title: "Roles & permissions" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Roles & permissions", "Ruoli e permessi") }
+}
export default async function RolesPage({ params }: PageProps<"/w/[slug]/settings/roles">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/rules/[id]/page.tsx b/src/app/w/[slug]/settings/rules/[id]/page.tsx
index 936206d..d71e1f3 100644
--- a/src/app/w/[slug]/settings/rules/[id]/page.tsx
+++ b/src/app/w/[slug]/settings/rules/[id]/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next"
+import { pageTitle } from "@/server/locale"
import { notFound } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db, schema } from "@/server/db"
@@ -26,7 +27,7 @@ async function loadRule(slug: string, id: string) {
export async function generateMetadata({ params }: PageProps<"/w/[slug]/settings/rules/[id]">): Promise {
const { slug, id } = await params
const rule = await loadRule(slug, id)
- return { title: rule ? rule.name : "Rule" }
+ return { title: rule ? rule.name : await pageTitle("Rule", "Regola") }
}
export default async function EditRulePage({ params }: PageProps<"/w/[slug]/settings/rules/[id]">) {
diff --git a/src/app/w/[slug]/settings/rules/new/page.tsx b/src/app/w/[slug]/settings/rules/new/page.tsx
index 4917dcf..be56d10 100644
--- a/src/app/w/[slug]/settings/rules/new/page.tsx
+++ b/src/app/w/[slug]/settings/rules/new/page.tsx
@@ -1,3 +1,5 @@
+import type { Metadata } from "next"
+import { pageTitle } from "@/server/locale"
import { notFound } from "next/navigation"
import { requireOrgPage } from "@/server/authz"
import { loadRuleOptions } from "@/server/workspace/queries/rules"
@@ -5,7 +7,9 @@ import { canUseScope } from "@/server/workspace/services/rules"
import { RuleBuilder } from "@/components/settings/rules/rule-builder"
import type { RuleScope } from "@/components/settings/rules/definitions"
-export const metadata = { title: "New rule" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("New rule", "Nuova regola") }
+}
export default async function NewRulePage({ params, searchParams }: PageProps<"/w/[slug]/settings/rules/new">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/rules/page.tsx b/src/app/w/[slug]/settings/rules/page.tsx
index 22c677f..44c6ee2 100644
--- a/src/app/w/[slug]/settings/rules/page.tsx
+++ b/src/app/w/[slug]/settings/rules/page.tsx
@@ -1,3 +1,5 @@
+import type { Metadata } from "next"
+import { pageTitle } from "@/server/locale"
import { notFound } from "next/navigation"
import { can, requireOrgPage } from "@/server/authz"
import { listRules, loadRuleLookup } from "@/server/workspace/queries/rules"
@@ -6,7 +8,9 @@ import { SettingsPage } from "@/components/settings/settings-ui"
import { RulesManager, type RuleScopeData } from "@/components/settings/rules/rules-manager"
import type { RuleScope } from "@/components/settings/rules/definitions"
-export const metadata = { title: "Rules" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Rules", "Regole") }
+}
export default async function RulesPage({ params, searchParams }: PageProps<"/w/[slug]/settings/rules">) {
const { slug } = await params
@@ -30,9 +34,9 @@ export default async function RulesPage({ params, searchParams }: PageProps<"/w/
return (
{
+ return { title: await pageTitle("Security & devices", "Sicurezza e dispositivi") }
+}
export default async function SecurityPage({ params }: PageProps<"/w/[slug]/settings/security">) {
const { slug } = await params
@@ -15,10 +18,10 @@ export default async function SecurityPage({ params }: PageProps<"/w/[slug]/sett
])
return (
{
+ return { title: await pageTitle("Signatures", "Firme") }
+}
export default async function SignaturesPage({ params }: PageProps<"/w/[slug]/settings/signatures">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/settings/teams/page.tsx b/src/app/w/[slug]/settings/teams/page.tsx
index 9f14664..656bf81 100644
--- a/src/app/w/[slug]/settings/teams/page.tsx
+++ b/src/app/w/[slug]/settings/teams/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { requireOrgPage, requirePagePermission } from "@/server/authz"
import { loadTeamsPage } from "@/server/workspace/queries/teams"
import { TeamsSettings } from "@/components/settings/teams/teams-settings"
-export const metadata: Metadata = { title: "Teams" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Teams", "Team") }
+}
export default async function TeamsPage({ params }: PageProps<"/w/[slug]/settings/teams">) {
const { slug } = await params
diff --git a/src/app/w/[slug]/tasks/page.tsx b/src/app/w/[slug]/tasks/page.tsx
index f0aecb8..449ec04 100644
--- a/src/app/w/[slug]/tasks/page.tsx
+++ b/src/app/w/[slug]/tasks/page.tsx
@@ -1,9 +1,12 @@
+import { pageTitle } from "@/server/locale"
import type { Metadata } from "next"
import { Suspense } from "react"
import { requireOrgPage } from "@/server/authz"
import { TasksView } from "@/components/tasks/tasks-view"
-export const metadata: Metadata = { title: "Tasks" }
+export async function generateMetadata(): Promise {
+ return { title: await pageTitle("Tasks", "Attività") }
+}
export default async function TasksPage({ params }: PageProps<"/w/[slug]/tasks">) {
const { slug } = await params
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index 126fae1..8e49bd9 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { ADMIN_NAV, SETTINGS_SECTIONS, isActivePath } from "@/components/admin/nav"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
export type AdminShellProps = {
instanceName: string
@@ -34,6 +35,8 @@ export type AdminShellProps = {
/** Chrome of the super admin panel: sidebar (desktop), sheet navigation (mobile), top bar. */
export function AdminShell({ instanceName, productName, homePath, user, children }: AdminShellProps) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [open, setOpen] = useState(false)
return (
@@ -46,13 +49,13 @@ export function AdminShell({ instanceName, productName, homePath, user, children
-
+
- Admin navigation
+ {it ? "Navigazione amministrazione" : "Admin navigation"}
setOpen(false)} />
@@ -61,13 +64,13 @@ export function AdminShell({ instanceName, productName, homePath, user, children
{instanceName}
- Instance admin
+ {it ? "Amministrazione istanza" : "Instance admin"}
- Open app
+ {it ? "Apri Dispatch" : "Open app"}
@@ -93,6 +96,10 @@ function SidebarContents({
onNavigate?: () => void
}) {
const pathname = usePathname()
+ const { locale } = useI18n()
+ const it = locale === "it"
+ const navLabel = (label: string) => it ? ({ Instance: "Istanza", Overview: "Panoramica", Workspaces: "Workspace", Users: "Utenti", Operations: "Operazioni", "Audit log": "Registro attività", System: "Sistema" }[label] ?? label) : label
+ const settingsLabel = (slug: string, label: string) => it ? ({ general: "Generale", authentication: "Accesso", email: "Invio email", oauth: "App OAuth", billing: "Fatturazione", storage: "Archiviazione", ai: "IA", branding: "Marchio", security: "Sicurezza", legal: "Note legali" }[slug] ?? label) : label
const settingsActive = isActivePath(pathname, "/admin/settings")
return (
@@ -105,19 +112,19 @@ function SidebarContents({
-
+
{ADMIN_NAV.slice(0, 1).map((group) => (
-
+
{group.items.map((item) => (
-
+
))}
))}
-
+
- {s.label}
+ {settingsLabel(s.slug, s.label)}
)
@@ -148,9 +155,9 @@ function SidebarContents({
{ADMIN_NAV.slice(1).map((group) => (
-
+
{group.items.map((item) => (
-
+
))}
))}
@@ -162,7 +169,7 @@ function SidebarContents({
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] text-muted-foreground outline-none transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
- Back to your workspace
+ {it ? "Torna al workspace" : "Back to your workspace"}
@@ -211,34 +218,36 @@ function NavLink({
function AdminUserMenu({ user, homePath }: { user: AdminShellProps["user"]; homePath: string }) {
const { theme, setTheme } = useTheme()
+ const { locale } = useI18n()
+ const it = locale === "it"
return (
- {user.name || "Instance owner"}
+ {user.name || (it ? "Proprietario istanza" : "Instance owner")}
{user.email}
- Open app
+ {it ? "Apri Dispatch" : "Open app"}
- {theme === "dark" ? : theme === "light" ? : } Theme
+ {theme === "dark" ? : theme === "light" ? : } {it ? "Tema" : "Theme"}
{[
- { v: "light", label: "Light", icon: Sun },
- { v: "dark", label: "Dark", icon: Moon },
- { v: "system", label: "System", icon: Monitor },
+ { v: "light", label: it ? "Chiaro" : "Light", icon: Sun },
+ { v: "dark", label: it ? "Scuro" : "Dark", icon: Moon },
+ { v: "system", label: it ? "Sistema" : "System", icon: Monitor },
].map((t) => (
setTheme(t.v)}>
{t.label}
@@ -249,7 +258,7 @@ function AdminUserMenu({ user, homePath }: { user: AdminShellProps["user"]; home
- Sign out
+ {it ? "Esci" : "Sign out"}
diff --git a/src/components/admin/audit/audit-filters.tsx b/src/components/admin/audit/audit-filters.tsx
index a45d549..185f62e 100644
--- a/src/components/admin/audit/audit-filters.tsx
+++ b/src/components/admin/audit/audit-filters.tsx
@@ -1,11 +1,13 @@
"use client"
import { useEffect, useRef, useState } from "react"
+import { DateField } from "@/components/ui/date-field"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { FilterSelect, SearchParamInput } from "@/components/admin/client"
+import { useI18n } from "@/lib/i18n"
const FILTER_PARAMS = ["org", "actor", "action", "from", "to", "actorId", "targetType", "targetId", "page"]
@@ -27,6 +29,7 @@ function useUpdateParams() {
/** Free-text action prefix with suggestions (prefixes + actions seen so far). */
function ActionPrefixInput({ suggestions }: { suggestions: string[] }) {
+ const { t, locale } = useI18n()
const params = useSearchParams()
const update = useUpdateParams()
const [value, setValue] = useState(params.get("action") ?? "")
@@ -42,8 +45,8 @@ function ActionPrefixInput({ suggestions }: { suggestions: string[] }) {
{
setValue("")
@@ -84,12 +87,11 @@ function DateInput({ param, label }: { param: "from" | "to"; label: string }) {
{label}
-
update({ [param]: e.target.value || null })}
+ onChange={(v) => update({ [param]: v || null })}
className="w-full bg-card sm:w-[150px]"
/>
@@ -97,6 +99,8 @@ function DateInput({ param, label }: { param: "from" | "to"; label: string }) {
}
export function AuditFilters({ workspaces, suggestions }: { workspaces: { id: string; name: string }[]; suggestions: string[] }) {
+ const { t, locale } = useI18n()
+ const it = locale === "it"
const params = useSearchParams()
const update = useUpdateParams()
// Remount the debounced text inputs after "Clear filters" so their local state resets
@@ -105,17 +109,17 @@ export function AuditFilters({ workspaces, suggestions }: { workspaces: { id: st
const scoped = [
params.get("actorId") && {
key: "actorId",
- label: "Actor",
+ label: it ? "Autore" : "Actor",
value: params.get("actorId")!,
},
params.get("targetType") && {
key: "targetType",
- label: "Target type",
+ label: it ? "Tipo elemento" : "Target type",
value: params.get("targetType")!,
},
params.get("targetId") && {
key: "targetId",
- label: "Target",
+ label: it ? "Elemento" : "Target",
value: params.get("targetId")!,
},
].filter(Boolean) as { key: string; label: string; value: string }[]
@@ -123,20 +127,20 @@ export function AuditFilters({ workspaces, suggestions }: { workspaces: { id: st
return (
-
+
({ value: w.id, label: w.name })),
]}
/>
-
-
+
+
{active && (
k + 1)
}}
>
- Clear filters
+ {it ? "Azzera filtri" : "Clear filters"}
)}
@@ -163,7 +167,7 @@ export function AuditFilters({ workspaces, suggestions }: { workspaces: { id: st
{s.value}
update({ [s.key]: null })}
>
diff --git a/src/components/admin/audit/audit-table.tsx b/src/components/admin/audit/audit-table.tsx
index 7c51967..a6c0c47 100644
--- a/src/components/admin/audit/audit-table.tsx
+++ b/src/components/admin/audit/audit-table.tsx
@@ -7,6 +7,7 @@ import { LocalTime } from "@/components/app/local-time"
import { UserAvatar } from "@/components/app/user-avatar"
import { ActionBadge } from "@/components/admin/audit/action-badge"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
/** Audit event prepared on the server; `iso` is rendered in the viewer's timezone via . */
export type AuditRowView = {
@@ -29,13 +30,14 @@ export type AuditRowView = {
}
function Actor({ row }: { row: AuditRowView }) {
+ const { locale } = useI18n()
if (!row.actorEmail) {
return (
SYS
- System
+ {locale === "it" ? "Sistema" : "System"}
)
}
@@ -81,42 +83,44 @@ function Target({ row }: { row: AuditRowView }) {
}
function Details({ row }: { row: AuditRowView }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
return (
-
Event id
+ {it ? "ID evento" : "Event id"}
{row.id}
-
Time
+ {it ? "Ora" : "Time"}
{row.iso}
{row.targetId && (
-
Target id
+ {it ? "ID elemento" : "Target id"}
{row.targetId}
)}
-
IP address
+ {it ? "Indirizzo IP" : "IP address"}
{row.ip ?? "—"}
-
User agent
+ {it ? "Browser e dispositivo" : "User agent"}
{row.userAgent ?? "—"}
-
Metadata
+
{it ? "Metadati" : "Metadata"}
{row.metadataJson ? (
{row.metadataJson}
) : (
-
No metadata recorded.
+
{it ? "Nessun metadato registrato." : "No metadata recorded."}
)}
@@ -124,6 +128,8 @@ function Details({ row }: { row: AuditRowView }) {
}
export function AuditTable({ rows }: { rows: AuditRowView[] }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [open, setOpen] = useState>(new Set())
const toggle = (id: string) =>
setOpen((prev) => {
@@ -141,13 +147,13 @@ export function AuditTable({ rows }: { rows: AuditRowView[] }) {
- {["", "Time", "Actor", "Action", "Target", "Workspace", "IP"].map((h, i) => (
+ {["", it ? "Ora" : "Time", it ? "Autore" : "Actor", it ? "Azione" : "Action", it ? "Elemento" : "Target", "Workspace", "IP"].map((h, i) => (
- {h || Details }
+ {h || {it ? "Dettagli" : "Details"} }
))}
@@ -170,7 +176,7 @@ export function AuditTable({ rows }: { rows: AuditRowView[] }) {
onClick={() => toggle(row.id)}
aria-expanded={expanded}
aria-controls={`audit-details-${row.id}`}
- aria-label={expanded ? "Hide details" : "Show details"}
+ aria-label={expanded ? (it ? "Nascondi dettagli" : "Hide details") : (it ? "Mostra dettagli" : "Show details")}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -195,7 +201,7 @@ export function AuditTable({ rows }: { rows: AuditRowView[] }) {
{row.orgName ?? row.orgId.slice(0, 8)}
) : (
- Instance
+ {it ? "Istanza" : "Instance"}
)}
@@ -234,9 +240,9 @@ export function AuditTable({ rows }: { rows: AuditRowView[] }) {
- {row.actorEmail ?? "System"}
+ {row.actorEmail ?? (it ? "Sistema" : "System")}
- {row.orgName ?? "Instance"}
+ {row.orgName ?? (it ? "Istanza" : "Instance")}
{row.targetLabel || row.targetType ? ` · ${row.targetLabel ?? row.targetType}` : ""}
@@ -250,12 +256,12 @@ export function AuditTable({ rows }: { rows: AuditRowView[] }) {
{row.targetHref && (
- Open {row.targetType ?? "target"}
+ {it ? "Apri" : "Open"} {row.targetType ?? (it ? "elemento" : "target")}
)}
{row.orgId && (
- Open workspace
+ {it ? "Apri workspace" : "Open workspace"}
)}
diff --git a/src/components/admin/client.tsx b/src/components/admin/client.tsx
index 6be0778..f54faf2 100644
--- a/src/components/admin/client.tsx
+++ b/src/components/admin/client.tsx
@@ -21,6 +21,7 @@ import {
} from "@/components/ui/alert-dialog"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils"
+import { localizeError, useI18n } from "@/lib/i18n"
export type ActionResultLike = { ok: true; data: T } | { ok: false; error: string; fieldErrors?: Record }
@@ -29,6 +30,7 @@ export type ActionResultLike = { ok: true; data: T } | { ok: false;
* success toast and a router refresh so server components re-render.
*/
export function useAdminAction() {
+ const { locale } = useI18n()
const router = useRouter()
const [pending, startTransition] = useTransition()
function run(
@@ -39,7 +41,7 @@ export function useAdminAction() {
startTransition(async () => {
try {
const res = await fn()
- if (!res.ok) toast.error(res.error)
+ if (!res.ok) toast.error(localizeError(res.error, locale))
else {
const msg = typeof opts.success === "function" ? opts.success(res.data) : opts.success
if (msg) toast.success(msg)
@@ -49,7 +51,7 @@ export function useAdminAction() {
resolve(res)
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong"
- toast.error(message)
+ toast.error(localizeError(message, locale))
resolve({ ok: false, error: message })
}
})
@@ -60,7 +62,7 @@ export function useAdminAction() {
export function CopyButton({
value,
- label = "Copy",
+ label,
className,
size = "icon-sm",
}: {
@@ -69,6 +71,8 @@ export function CopyButton({
className?: string
size?: "icon-sm" | "icon-xs" | "sm"
}) {
+ const { t } = useI18n()
+ const buttonLabel = label ?? t("copy")
const [copied, setCopied] = useState(false)
return (
{
try {
await navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
- toast.error("Couldn't copy to clipboard")
+ toast.error(t("couldNotCopy"))
}
}}
>
{copied ? : }
- {size === "sm" && (copied ? "Copied" : label)}
+ {size === "sm" && (copied ? t("copied") : buttonLabel)}
)
}
@@ -132,6 +136,7 @@ export function SearchParamInput({
placeholder?: string
className?: string
}) {
+ const { t } = useI18n()
const params = useSearchParams()
const update = useParamUpdater()
const initial = params.get(param) ?? ""
@@ -161,7 +166,7 @@ export function SearchParamInput({
{value && (
{
setValue("")
@@ -187,6 +192,7 @@ export function FilterSelect({
label: string
className?: string
}) {
+ const { t } = useI18n()
const params = useSearchParams()
const update = useParamUpdater()
const value = params.get(param) ?? "all"
@@ -196,7 +202,7 @@ export function FilterSelect({
- {label}: all
+ {label}: {t("allOption")}
{options.map((o) => (
{o.label}
@@ -209,6 +215,7 @@ export function FilterSelect({
/** Prev/next pagination driven by the `page` search param. */
export function Pager({ page, pageSize, total }: { page: number; pageSize: number; total: number }) {
+ const { t } = useI18n()
const pathname = usePathname()
const params = useSearchParams()
const pages = Math.max(1, Math.ceil(total / pageSize))
@@ -224,29 +231,29 @@ export function Pager({ page, pageSize, total }: { page: number; pageSize: numbe
return (
- {from}–{to} of {new Intl.NumberFormat("en-US").format(total)}
+ {from}–{to} {t("of")} {new Intl.NumberFormat("en-US").format(total)}
{page > 1 ? (
- Previous
+ {t("previous")}
) : (
- Previous
+ {t("previous")}
)}
{page < pages ? (
- Next
+ {t("next")}
) : (
- Next
+ {t("next")}
)}
@@ -290,6 +297,7 @@ export function ConfirmButton
({
}) {
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState("")
+ const { t } = useI18n()
const { pending, run } = useAdminAction()
const blocked = Boolean(confirmText) && typed.trim() !== confirmText
return (
@@ -313,13 +321,13 @@ export function ConfirmButton({
{confirmText && (
- Type {confirmText} to confirm
+ {t("typeToConfirm")} {confirmText} {t("toConfirm")}
setTyped(e.target.value)} autoComplete="off" autoFocus />
)}
- Cancel
+ {t("cancel")}
({
}}
>
{pending && }
- {confirmLabel}
+ {confirmLabel === "Confirm" ? t("confirm") : confirmLabel}
diff --git a/src/components/admin/email-delivery-fields.tsx b/src/components/admin/email-delivery-fields.tsx
index f0acee1..cc388f2 100644
--- a/src/components/admin/email-delivery-fields.tsx
+++ b/src/components/admin/email-delivery-fields.tsx
@@ -11,6 +11,7 @@ import { ChoiceCards } from "@/components/admin/choice-cards"
import { SecretInput } from "@/components/admin/secret-input"
import { testEmailAction } from "@/app/admin/settings/actions"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
export type EmailDeliveryValue = {
provider: "log" | "smtp" | "ses"
@@ -86,11 +87,13 @@ export function EmailDeliveryFields({
defaultFromEmail?: string
fieldErrors?: Record
}) {
+ const { t, locale } = useI18n()
+ const it = locale === "it"
const err = (k: string) => fieldErrors?.[k] ?? fieldErrors?.[`values.${k}`] ?? fieldErrors?.[`config.${k}`]
return (
@@ -101,23 +104,22 @@ export function EmailDeliveryFields({
)
}
options={[
- { value: "ses", title: "Amazon SES", description: "Reliable, low-cost delivery via the SES SMTP interface.", icon: Cloud },
- { value: "smtp", title: "SMTP server", description: "Postmark, Mailgun, Resend, Brevo or your own server.", icon: Server },
- { value: "log", title: "Log only", description: "Print emails to the server logs. Fine for trying things out.", icon: Terminal },
+ { value: "ses", title: "Amazon SES", description: t("amazonSesDescription"), icon: Cloud },
+ { value: "smtp", title: t("smtpServer"), description: t("smtpDescription"), icon: Server },
+ { value: "log", title: t("logOnly"), description: t("logDescription"), icon: Terminal },
]}
/>
{value.provider === "log" && (
- Emails are not delivered. Sign-in links, codes and invitations are printed to the server output — view them with{" "}
- docker compose logs -f app. Anyone
- who needs to sign in will need you to relay their code, so configure a provider before inviting your team.
+ {t("emailNotDelivered")} {" "}
+ docker compose logs -f app. {it ? "Chi deve accedere avrà bisogno che tu gli comunichi il codice: configura un provider prima di invitare il team." : "Anyone who needs to sign in will need you to relay their code, so configure a provider before inviting your team."}
)}
{value.provider === "ses" && (
-
+
onChange({ sesRegion })}>
@@ -131,7 +133,7 @@ export function EmailDeliveryFields({
-
+
onChange({ port: Number(p), secure: p === "465" })}
@@ -147,11 +149,11 @@ export function EmailDeliveryFields({
- Create SMTP credentials in the SES console → SMTP settings . These are not your AWS access keys.
+ {it ? "Crea le credenziali SMTP nella console SES → " : "Create SMTP credentials in the SES console → "}SMTP settings {it ? ". Non sono le chiavi di accesso AWS." : ". These are not your AWS access keys."}
>
}
>
@@ -166,7 +168,7 @@ export function EmailDeliveryFields({
aria-invalid={Boolean(err("user"))}
/>
-
+
@@ -174,7 +176,7 @@ export function EmailDeliveryFields({
{value.provider === "smtp" && (
-
+
-
+
-
+
onChange({ secure: v === "tls" })}>
@@ -208,7 +210,7 @@ export function EmailDeliveryFields({
-
+
-
+
@@ -226,7 +228,7 @@ export function EmailDeliveryFields({
{value.provider !== "log" && (
-
+
@@ -249,7 +251,7 @@ export function EmailDeliveryFields({
/>
{showReplyTo && (
-
+
(null)
@@ -289,7 +292,7 @@ export function TestEmailPanel({
const res = await testEmailAction({ config, password, to })
setResult(res.ok ? { ok: true, message: res.data.message } : { ok: false, message: res.error })
} catch {
- setResult({ ok: false, message: "Could not reach the server." })
+ setResult({ ok: false, message: t("couldNotReachServer") })
} finally {
setPending(false)
}
@@ -299,7 +302,7 @@ export function TestEmailPanel({
- Send a test to
+ {t("sendTestTo")}
- {pending ? : } Send test email
+ {pending ? : } {t("sendTestEmail")}
{result && (
diff --git a/src/components/admin/overview/growth-chart.tsx b/src/components/admin/overview/growth-chart.tsx
index fc6398b..9a3357a 100644
--- a/src/components/admin/overview/growth-chart.tsx
+++ b/src/components/admin/overview/growth-chart.tsx
@@ -5,21 +5,24 @@ import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
export type GrowthPoint = { day: string; signups: number; workspaces: number }
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
/** "2026-09-27" → "Sep 27" (parsed manually: no timezone shifts, SSR-stable). */
-function shortDay(day: string) {
+function shortDay(day: string, locale: "en" | "it" = "en") {
const [, m, d] = day.split("-").map(Number)
- return `${MONTHS[(m ?? 1) - 1]} ${d}`
+ const month = locale === "it" ? ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"][(m ?? 1) - 1] : MONTHS[(m ?? 1) - 1]
+ return `${month} ${d}`
}
-function longDay(day: string) {
+function longDay(day: string, locale: "en" | "it" = "en") {
const [y, m, d] = day.split("-").map(Number)
- const weekday = new Date(Date.UTC(y!, (m ?? 1) - 1, d)).toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" })
- return `${weekday}, ${MONTHS[(m ?? 1) - 1]} ${d}`
+ const localeName = locale === "it" ? "it-IT" : "en-US"
+ const weekday = new Date(Date.UTC(y!, (m ?? 1) - 1, d)).toLocaleDateString(localeName, { weekday: "short", timeZone: "UTC" })
+ return `${weekday}, ${shortDay(day, locale)}`
}
/*
@@ -33,19 +36,14 @@ const SERIES_COLOR = {
dark: "oklch(0.66 0.16 152)",
}
-const config = {
- signups: { label: "New users", theme: SERIES_COLOR },
- workspaces: { label: "New workspaces", theme: SERIES_COLOR },
-} satisfies ChartConfig
-
-function Delta({ current, previous }: { current: number; previous: number }) {
+function Delta({ current, previous, locale }: { current: number; previous: number; locale: "en" | "it" }) {
const diff = current - previous
- if (current === 0 && previous === 0) return
No change
+ if (current === 0 && previous === 0) return
{locale === "it" ? "Nessuna variazione" : "No change"}
const sign = diff > 0 ? "+" : diff < 0 ? "−" : "±"
return (
0 && "text-emerald-700 dark:text-emerald-400")}>
{sign}
- {Math.abs(diff)} vs previous 30 days
+ {Math.abs(diff)} {locale === "it" ? "rispetto ai 30 giorni precedenti" : "vs previous 30 days"}
)
}
@@ -56,13 +54,16 @@ function SeriesChart({
total,
previous,
points,
+ locale,
}: {
dataKey: "signups" | "workspaces"
title: string
total: number
previous: number
points: GrowthPoint[]
+ locale: "en" | "it"
}) {
+ const chartConfig = { signups: { label: locale === "it" ? "Nuovi utenti" : "New users", theme: SERIES_COLOR }, workspaces: { label: locale === "it" ? "Nuovi workspace" : "New workspaces", theme: SERIES_COLOR } } satisfies ChartConfig
// Weekly ticks counted back from today so the latest day is always labeled
const ticks = points.filter((_, i) => (points.length - 1 - i) % 7 === 0).map((p) => p.day)
return (
@@ -72,18 +73,18 @@ function SeriesChart({
{total.toLocaleString("en-US")}
-
+
-
+
shortDay(day, locale)}
tickLine={false}
axisLine={false}
tickMargin={8}
@@ -107,7 +108,7 @@ function SeriesChart({
indicator="line"
labelFormatter={(_, payload) => {
const day = payload?.[0]?.payload?.day as string | undefined
- return day ? longDay(day) : ""
+ return day ? longDay(day, locale) : ""
}}
/>
}
@@ -117,7 +118,7 @@ function SeriesChart({
{total === 0 && (
- Nothing in the last 30 days
+ {locale === "it" ? "Nessun dato negli ultimi 30 giorni" : "Nothing in the last 30 days"}
)}
@@ -135,69 +136,71 @@ export function GrowthChart({
totals: { signups: number; workspaces: number }
previous: { signups: number; workspaces: number }
}) {
+ const { locale } = useI18n()
const [view, setView] = useState<"chart" | "table">("chart")
return (
-
Daily, last 30 days
+
{locale === "it" ? "Giornaliero, ultimi 30 giorni" : "Daily, last 30 days"}
v && setView(v as "chart" | "table")}
- aria-label="Growth view"
+ aria-label={locale === "it" ? "Visualizzazione crescita" : "Growth view"}
>
- Chart
+ {locale === "it" ? "Grafico" : "Chart"}
- Table
+ {locale === "it" ? "Tabella" : "Table"}
{view === "chart" ? (
-
+
) : (
- New users and workspaces per day, last 30 days
+ {locale === "it" ? "Nuovi utenti e workspace al giorno, ultimi 30 giorni" : "New users and workspaces per day, last 30 days"}
- Day
+ {locale === "it" ? "Giorno" : "Day"}
- New users
+ {locale === "it" ? "Nuovi utenti" : "New users"}
- New workspaces
+ {locale === "it" ? "Nuovi workspace" : "New workspaces"}
{[...points].reverse().map((p) => (
- {longDay(p.day)}
+ {longDay(p.day, locale)}
{p.signups}
{p.workspaces}
diff --git a/src/components/admin/overview/recent-lists.tsx b/src/components/admin/overview/recent-lists.tsx
index 2271741..edcf361 100644
--- a/src/components/admin/overview/recent-lists.tsx
+++ b/src/components/admin/overview/recent-lists.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import Link from "next/link"
import { Building2, ScrollText } from "lucide-react"
import type { RecentWorkspace } from "@/server/admin/overview"
@@ -6,6 +8,7 @@ import { UserAvatar } from "@/components/app/user-avatar"
import { EmptyState, StatusBadge, type Tone } from "@/components/admin/ui"
import { LocalTime } from "@/components/app/local-time"
import { ActionBadge } from "@/components/admin/audit/action-badge"
+import { useI18n } from "@/lib/i18n"
const PLAN_LABEL: Record = {
self_hosted: "Self-hosted",
@@ -14,42 +17,44 @@ const PLAN_LABEL: Record = {
comped: "Comped",
}
-export function workspaceStatus(w: { plan: string; subscriptionStatus: string; suspendedAt: Date | null }): {
+export function workspaceStatus(w: { plan: string; subscriptionStatus: string; suspendedAt: Date | null }, locale: "en" | "it" = "en"): {
tone: Tone
label: string
} {
- if (w.suspendedAt) return { tone: "error", label: "Suspended" }
- if (w.plan !== "cloud") return { tone: "neutral", label: PLAN_LABEL[w.plan] ?? w.plan }
+ if (w.suspendedAt) return { tone: "error", label: locale === "it" ? "Sospeso" : "Suspended" }
+ if (w.plan !== "cloud") return { tone: "neutral", label: locale === "it" ? ({ self_hosted: "Installato in autonomia", free: "Gratuito", cloud: "Cloud", comped: "Gratuito" }[w.plan] ?? w.plan) : (PLAN_LABEL[w.plan] ?? w.plan) }
switch (w.subscriptionStatus) {
case "active":
- return { tone: "ok", label: "Active" }
+ return { tone: "ok", label: locale === "it" ? "Attivo" : "Active" }
case "trialing":
- return { tone: "info", label: "Trial" }
+ return { tone: "info", label: locale === "it" ? "Prova" : "Trial" }
case "past_due":
- return { tone: "warn", label: "Past due" }
+ return { tone: "warn", label: locale === "it" ? "Pagamento in ritardo" : "Past due" }
case "canceled":
case "unpaid":
case "incomplete":
- return { tone: "error", label: w.subscriptionStatus.replace("_", " ") }
+ return { tone: "error", label: locale === "it" ? ({ canceled: "Annullato", unpaid: "Non pagato", incomplete: "Incompleto" }[w.subscriptionStatus] ?? w.subscriptionStatus) : w.subscriptionStatus.replace("_", " ") }
default:
- return { tone: "neutral", label: "No subscription" }
+ return { tone: "neutral", label: locale === "it" ? "Nessun abbonamento" : "No subscription" }
}
}
export function RecentWorkspacesList({ workspaces }: { workspaces: RecentWorkspace[] }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
if (!workspaces.length) {
return (
)
}
return (
{workspaces.map((w) => {
- const status = workspaceStatus(w)
+ const status = workspaceStatus(w, locale)
return (
{w.name}
- {w.ownerEmail ?? "No owner"} · {w.members} {w.members === 1 ? "member" : "members"}
+ {w.ownerEmail ?? (it ? "Nessun proprietario" : "No owner")} · {w.members} {it ? (w.members === 1 ? "membro" : "membri") : (w.members === 1 ? "member" : "members")}
@@ -82,12 +87,14 @@ export function RecentWorkspacesList({ workspaces }: { workspaces: RecentWorkspa
}
export function RecentAuditList({ events }: { events: AuditEvent[] }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
if (!events.length) {
return (
)
}
@@ -105,10 +112,10 @@ export function RecentAuditList({ events }: { events: AuditEvent[] }) {
- {e.orgName &&
in {e.orgName} }
+ {e.orgName &&
{it ? "nel workspace" : "in"} {e.orgName} }
- {e.actorEmail ?? "System"}
+ {e.actorEmail ?? (it ? "Sistema" : "System")}
{e.targetLabel && e.targetLabel !== e.actorEmail ? ` → ${e.targetLabel}` : ""}
diff --git a/src/components/admin/overview/setup-checklist.tsx b/src/components/admin/overview/setup-checklist.tsx
index 4059271..a644398 100644
--- a/src/components/admin/overview/setup-checklist.tsx
+++ b/src/components/admin/overview/setup-checklist.tsx
@@ -1,10 +1,15 @@
+"use client"
+
import Link from "next/link"
import { ArrowRight, Check, CircleDashed, Minus } from "lucide-react"
import type { ChecklistItem } from "@/server/admin/overview"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
/** Instance readiness checklist; every row links to the page that fixes it. */
export function SetupChecklist({ items }: { items: ChecklistItem[] }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const required = items.filter((i) => i.state !== "optional")
const done = required.filter((i) => i.state === "done").length
const pct = required.length ? Math.round((done / required.length) * 100) : 100
@@ -13,7 +18,7 @@ export function SetupChecklist({ items }: { items: ChecklistItem[] }) {
- {done} of {required.length} essentials ready
+ {it ? `${done} di ${required.length} elementi essenziali pronti` : `${done} of ${required.length} essentials ready`}
{pct}%
@@ -23,7 +28,7 @@ export function SetupChecklist({ items }: { items: ChecklistItem[] }) {
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
- aria-label="Setup progress"
+ aria-label={it ? "Avanzamento configurazione" : "Setup progress"}
>
@@ -54,15 +59,15 @@ export function SetupChecklist({ items }: { items: ChecklistItem[] }) {
- {item.label}
+ {it ? ({ "Email delivery": "Invio email", Billing: "Fatturazione", "Google & Microsoft": "Google e Microsoft", "Attachment storage": "Archiviazione allegati", "Background worker": "Worker in background" }[item.label] ?? item.label) : item.label}
- {item.state === "done" ? "(done)" : item.state === "todo" ? "(needs attention)" : "(optional)"}
+ {item.state === "done" ? (it ? "(completato)" : "(done)") : item.state === "todo" ? (it ? "(richiede attenzione)" : "(needs attention)") : (it ? "(facoltativo)" : "(optional)")}
{item.state === "optional" && (
- Optional
+ {it ? "Facoltativo" : "Optional"}
)}
- {item.description}
+ {it ? translateChecklistDescription(item.key, item.description) : item.description}
@@ -72,3 +77,12 @@ export function SetupChecklist({ items }: { items: ChecklistItem[] }) {
)
}
+
+function translateChecklistDescription(key: string, value: string) {
+ if (key === "email") return value.startsWith("Sending via") ? value.replace("Sending via", "Invio tramite") : "I codici di accesso e gli inviti vengono stampati solo nei log del server."
+ if (key === "billing") return ({ "Not needed — the instance runs in private mode.": "Non necessaria: l’istanza è in modalità privata.", "Stripe subscriptions are active for hosted workspaces.": "Gli abbonamenti Stripe sono attivi per i workspace ospitati.", "Add the Stripe secret key and create the product & price.": "Aggiungi la chiave segreta Stripe e crea prodotto e prezzo.", "Enable Stripe billing to charge hosted workspaces.": "Attiva la fatturazione Stripe per addebitare i workspace ospitati." }[value] ?? value)
+ if (key === "oauth") return value.startsWith("Optional") ? "Facoltativo: permette ai team di collegare Gmail e Outlook con un clic." : value.replace("OAuth apps are configured.", "Le app OAuth sono configurate.").replace("OAuth app is configured.", "L’app OAuth è configurata.")
+ if (key === "storage") return value.startsWith("S3-compatible bucket") ? value.replace("S3-compatible bucket", "Bucket compatibile con S3") : value === "S3 is selected but bucket or credentials are missing." ? "S3 è selezionato, ma mancano il bucket o le credenziali." : "Disco locale nel volume dei dati. Passa a S3 per configurazioni con più nodi."
+ if (key === "worker") return ({ "Online — syncing inboxes and running jobs.": "Online: sincronizza le caselle ed esegue le attività.", "Offline — no heartbeat in the last 90 seconds.": "Offline: nessun segnale negli ultimi 90 secondi.", "Has not reported yet. Start the worker container.": "Nessun segnale ricevuto. Avvia il container worker." }[value] ?? value)
+ return value
+}
diff --git a/src/components/admin/secret-input.tsx b/src/components/admin/secret-input.tsx
index bc2dae1..5ae9667 100644
--- a/src/components/admin/secret-input.tsx
+++ b/src/components/admin/secret-input.tsx
@@ -5,6 +5,7 @@ import { Eye, EyeOff, KeyRound, Undo2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
/**
* Input for a stored secret. The current value is never sent to the client —
@@ -34,6 +35,8 @@ export function SecretInput({
className?: string
"aria-invalid"?: boolean
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [editing, setEditing] = useState(!preview)
const [draft, setDraft] = useState("")
const [visible, setVisible] = useState(false)
@@ -50,20 +53,20 @@ export function SecretInput({
>
{preview}
- {!clearing && stored encrypted }
+ {!clearing && {it ? "memorizzato in forma cifrata" : "stored encrypted"} }
{clearing ? (
onChange(undefined)} disabled={disabled}>
- Undo
+ {it ? "Annulla" : "Undo"}
) : (
<>
setEditing(true)} disabled={disabled}>
- Replace
+ {it ? "Sostituisci" : "Replace"}
{allowClear && (
onChange("")} disabled={disabled}>
- Remove
+ {it ? "Rimuovi" : "Remove"}
)}
>
@@ -81,7 +84,7 @@ export function SecretInput({
autoComplete="new-password"
spellCheck={false}
value={draft}
- placeholder={placeholder}
+ placeholder={it && placeholder === "Paste the new value" ? "Incolla il nuovo valore" : placeholder}
disabled={disabled}
aria-invalid={ariaInvalid}
className="pr-9 font-mono text-[13px]"
@@ -94,7 +97,7 @@ export function SecretInput({
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute top-1/2 right-1.5 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground"
- aria-label={visible ? "Hide value" : "Show value"}
+ aria-label={visible ? (it ? "Nascondi valore" : "Hide value") : (it ? "Mostra valore" : "Show value")}
>
{visible ? : }
diff --git a/src/components/admin/settings/ai-form.tsx b/src/components/admin/settings/ai-form.tsx
index 6a2bc44..2475a3c 100644
--- a/src/components/admin/settings/ai-form.tsx
+++ b/src/components/admin/settings/ai-form.tsx
@@ -10,6 +10,7 @@ import { ChoiceCards } from "@/components/admin/choice-cards"
import { SecretInput } from "@/components/admin/secret-input"
import { GRID_FIELD, SettingField, SettingsFormShell, SwitchField, TestResult, useSettingsForm } from "@/components/admin/settings/form"
import { testAiAction } from "@/app/admin/settings/actions"
+import { useI18n } from "@/lib/i18n"
export type AiSettingsValue = {
enabled: boolean
@@ -23,6 +24,8 @@ export type AiSettingsValue = {
const ANTHROPIC_MODELS = ["claude-sonnet-5", "claude-opus-5", "claude-haiku-4-5"]
export function AiSettingsForm({ initial }: { initial: AiSettingsValue }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("ai", initial)
const v = form.values
const [testing, setTesting] = useState(false)
@@ -40,7 +43,7 @@ export function AiSettingsForm({ initial }: { initial: AiSettingsValue }) {
: { ok: false, message: res.error }
)
} catch {
- setResult({ ok: false, message: "Could not reach the server." })
+ setResult({ ok: false, message: it ? "Impossibile raggiungere il server." : "Could not reach the server." })
} finally {
setTesting(false)
}
@@ -49,43 +52,43 @@ export function AiSettingsForm({ initial }: { initial: AiSettingsValue }) {
return (
Enabled : Disabled
+ form.baseline.enabled ? {it ? "Attiva" : "Enabled"} : {it ? "Disattivata" : "Disabled"}
}
>
form.set({ enabled })}
/>
form.set({ allowOrgKeys })}
/>
-
+
form.set({ provider })}
options={[
- { value: "anthropic", title: "Anthropic Claude", description: "Recommended. Uses the Messages API.", icon: Sparkles },
- { value: "openai", title: "OpenAI-compatible", description: "Any /chat/completions endpoint.", icon: Boxes },
+ { value: "anthropic", title: "Anthropic Claude", description: it ? "Consigliato. Usa la Messages API." : "Recommended. Uses the Messages API.", icon: Sparkles },
+ { value: "openai", title: "Compatibile con OpenAI", description: it ? "Qualsiasi endpoint /chat/completions." : "Any /chat/completions endpoint.", icon: Boxes },
]}
/>
) : (
- "Optional for local servers such as Ollama."
+ it ? "Facoltativa per server locali come Ollama." : "Optional for local servers such as Ollama."
)
}
>
@@ -117,11 +120,11 @@ export function AiSettingsForm({ initial }: { initial: AiSettingsValue }) {
/>
-
+
- {testing ? : } Test AI provider
+ {testing ? : } {it ? "Prova provider IA" : "Test AI provider"}
- {!hasKey && v.provider === "anthropic" && Add an API key to test. }
+ {!hasKey && v.provider === "anthropic" && {it ? "Aggiungi una chiave API per eseguire la prova." : "Add an API key to test."} }
diff --git a/src/components/admin/settings/auth-form.tsx b/src/components/admin/settings/auth-form.tsx
index be81ff3..0ce7bc1 100644
--- a/src/components/admin/settings/auth-form.tsx
+++ b/src/components/admin/settings/auth-form.tsx
@@ -6,6 +6,7 @@ import { Panel } from "@/components/admin/ui"
import { ChoiceCards } from "@/components/admin/choice-cards"
import { DOMAIN_RE, TagInput } from "@/components/admin/tag-input"
import { Callout, NumberInput, SettingField, SettingsFormShell, SwitchField, useSettingsForm } from "@/components/admin/settings/form"
+import { useI18n } from "@/lib/i18n"
export type AuthSettingsValue = {
signupMode: "open" | "invite_only" | "domains"
@@ -28,27 +29,28 @@ export function AuthSettingsForm({
mode: "private" | "saas"
}) {
const form = useSettingsForm("auth", initial)
+ const { t, locale } = useI18n()
const v = form.values
return (
-
+
form.set({ signupMode })}
options={[
- { value: "invite_only", title: "Invite only", description: "Only people invited to a workspace can create an account.", icon: Lock },
- { value: "domains", title: "Allowed domains", description: "Anyone with an email address at an approved domain.", icon: AtSign },
- { value: "open", title: "Open sign-up", description: "Anyone can create an account with their email.", icon: Globe2 },
+ { value: "invite_only", title: t("authInviteOnly"), description: t("authInviteOnlyDesc"), icon: Lock },
+ { value: "domains", title: t("authAllowedDomains"), description: t("authAllowedDomainsDesc"), icon: AtSign },
+ { value: "open", title: t("authOpenSignUp"), description: t("authOpenSignUpDesc"), icon: Globe2 },
]}
/>
{v.signupMode === "domains" && (
form.set({ allowedSignupDomains })}
placeholder="acme.com, acme.co.uk"
normalize={(d) => d.trim().toLowerCase().replace(/^@/, "")}
- validate={(d) => (DOMAIN_RE.test(d) ? null : `“${d}” is not a valid domain`)}
+ validate={(d) => (DOMAIN_RE.test(d) ? null : t("authInvalidDomain").replace("{domain}", d))}
aria-invalid={Boolean(form.error("allowedSignupDomains"))}
/>
@@ -65,23 +67,22 @@ export function AuthSettingsForm({
)}
{v.signupMode === "open" && mode === "private" && (
- This instance is in private mode but sign-up is open: anyone who finds the URL can create an account
- {v.workspaceCreation === "anyone" ? " and a workspace" : ""}.
+ {t("authPrivateOpenWarning").replace("{suffix}", v.workspaceCreation === "anyone" ? " and a workspace" : "")}
)}
-
+
form.set({ workspaceCreation })}
options={[
- { value: "anyone", title: "Anyone who can sign in", description: "Every user can create their own workspaces.", icon: Users },
+ { value: "anyone", title: t("authAnyoneSignIn"), description: t("authAnyoneSignInDesc"), icon: Users },
{
value: "super_admins",
- title: "Instance admins only",
- description: "Other users see a note to ask for an invitation.",
+ title: t("authAdminsOnly"),
+ description: t("authAdminsOnlyDesc"),
icon: ShieldCheck,
},
]}
@@ -89,16 +90,16 @@ export function AuthSettingsForm({
@@ -108,23 +109,23 @@ export function AuthSettingsForm({
onChange={(verifiedAutoJoinDomains) => form.set({ verifiedAutoJoinDomains })}
placeholder="acme.com"
normalize={(d) => d.trim().toLowerCase().replace(/^@/, "")}
- validate={(d) => (DOMAIN_RE.test(d) ? null : `“${d}” is not a valid domain`)}
+ validate={(d) => (DOMAIN_RE.test(d) ? null : t("authInvalidDomain").replace("{domain}", d))}
aria-invalid={Boolean(form.error("verifiedAutoJoinDomains"))}
/>
{mode === "saas" && v.verifiedAutoJoinDomains.length === 0 && (
- No domains approved: auto-join is off for every workspace. Public email providers can never be approved.
+ {t("authNoDomains")}
)}
-
+
form.set({ sessionDays })}
aria-invalid={Boolean(form.error("sessionDays"))}
/>
form.set({ magicLinkMinutes })}
aria-invalid={Boolean(form.error("magicLinkMinutes"))}
/>
@@ -157,23 +158,23 @@ export function AuthSettingsForm({
- Configure and enable the Google app in{" "}
+ {t("authConfigureGoogle")}{" "}
- OAuth apps
+ {t("authOAuthApps")}
{" "}
- first.
+ {locale === "it" ? "prima." : "first."}
>
)
}
@@ -183,17 +184,17 @@ export function AuthSettingsForm({
/>
- Configure and enable the Microsoft app in{" "}
+ {t("authConfigureMicrosoft")}{" "}
- OAuth apps
+ {t("authOAuthApps")}
{" "}
- first.
+ {locale === "it" ? "prima." : "first."}
>
)
}
diff --git a/src/components/admin/settings/billing-form.tsx b/src/components/admin/settings/billing-form.tsx
index 82699c2..1be0d68 100644
--- a/src/components/admin/settings/billing-form.tsx
+++ b/src/components/admin/settings/billing-form.tsx
@@ -20,6 +20,7 @@ import {
useSettingsForm,
} from "@/components/admin/settings/form"
import { createStripePriceAction } from "@/app/admin/settings/billing-actions"
+import { useI18n } from "@/lib/i18n"
export type BillingSettingsValue = {
enabled: boolean
@@ -92,6 +93,8 @@ export function BillingSettingsForm({
mode: "private" | "saas"
webhookUrl: string
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("billing", initial)
const v = form.values
const { pending, run } = useAdminAction()
@@ -102,47 +105,46 @@ export function BillingSettingsForm({
{mode !== "saas" && (
- Billing only applies when the instance runs in Public SaaS mode. This instance is private, so workspaces are
- never charged. Change the mode in{" "}
+ {it ? "La fatturazione si applica solo in modalità " : "Billing only applies when the instance runs in "}Public SaaS {it ? ". Questa istanza è privata, quindi i workspace non vengono mai addebitati. Cambia modalità in " : " mode. This instance is private, so workspaces are never charged. Change the mode in "}{" "}
- General settings
+ {it ? "Impostazioni generali" : "General settings"}
.
)}
Billing active
+ {it ? "Fatturazione attiva" : "Billing active"}
) : (
- Billing off
+ {it ? "Fatturazione disattivata" : "Billing off"}
)
}
>
form.set({ enabled })}
/>
form.set({ enforce })}
/>
-
+
-
+
-
+
form.set({ currency })}>
@@ -166,58 +168,58 @@ export function BillingSettingsForm({
-
+
form.set({ interval: interval as "month" | "year" })}>
- Monthly
- Yearly
+ {it ? "Mensile" : "Monthly"}
+ {it ? "Annuale" : "Yearly"}
-
+
form.set({ trialDays })}
aria-invalid={Boolean(form.error("trialDays"))}
/>
- Customers see:{" "}
+ {it ? "I clienti vedranno:" : "Customers see:"}{" "}
- {v.trialDays > 0 ? `${v.trialDays}-day free trial, then ` : ""}
- {formatMoney(v.amount, v.currency)}/{v.interval} per workspace
+ {v.trialDays > 0 ? (it ? `${v.trialDays} giorni di prova gratuita, poi ` : `${v.trialDays}-day free trial, then `) : ""}
+ {formatMoney(v.amount, v.currency)}/{it ? (v.interval === "month" ? "mese" : "anno") : v.interval} {it ? "per workspace" : "per workspace"}
- Find them in the Stripe dashboard under{" "}
+ {it ? "Le trovi nella dashboard Stripe in " : "Find them in the Stripe dashboard under "}{" "}
- Developers → API keys
+ {it ? "Sviluppatori → Chiavi API" : "Developers → API keys"}
- . Use test keys (sk_test_…) while trying things out.
+ {it ? ". Usa chiavi di test (sk_test_…) durante le prove." : ". Use test keys (sk_test_…) while trying things out."}
>
}
- actions={hasSecretKey ? {testMode ? "Test mode" : "Live mode"} : null}
+ actions={hasSecretKey ? {testMode ? (it ? "Modalità test" : "Test mode") : (it ? "Modalità live" : "Live mode")} : null}
>
-
+
-
+
-
+
-
+
-
+
{STRIPE_WEBHOOK_EVENTS.map((e) => (
@@ -256,7 +258,7 @@ export function BillingSettingsForm({
))}
-
+
- Product ID
+ ID prodotto
{form.baseline.productId ? (
) : (
- Not created yet
+ {it ? "Non ancora creato" : "Not created yet"}
)}
- Price ID
+ ID prezzo
{form.baseline.priceId ? (
) : (
- Not created yet
+ {it ? "Non ancora creato" : "Not created yet"}
)}
@@ -299,19 +301,19 @@ export function BillingSettingsForm({
disabled={pending || form.dirty || !hasSecretKey}
onClick={() =>
run(() => createStripePriceAction({}), {
- success: (d) => `Stripe price ${d.priceId} is ready`,
+ success: (d) => it ? `Prezzo Stripe ${d.priceId} pronto` : `Stripe price ${d.priceId} is ready`,
})
}
>
{pending ?
:
}
- {form.baseline.priceId ? "Refresh product & price" : "Create product & price"}
+ {form.baseline.priceId ? (it ? "Aggiorna prodotto e prezzo" : "Refresh product & price") : (it ? "Crea prodotto e prezzo" : "Create product & price")}
{!hasSecretKey
- ? "Save a Stripe secret key first."
+ ? (it ? "Salva prima una chiave segreta Stripe." : "Save a Stripe secret key first.")
: form.dirty
- ? "Save your changes first."
- : "Run it again after changing the price, currency or interval."}
+ ? (it ? "Salva prima le modifiche." : "Save your changes first.")
+ : (it ? "Eseguilo di nuovo dopo aver modificato prezzo, valuta o intervallo." : "Run it again after changing the price, currency or interval.")}
diff --git a/src/components/admin/settings/branding-form.tsx b/src/components/admin/settings/branding-form.tsx
index 56abd2c..a679b11 100644
--- a/src/components/admin/settings/branding-form.tsx
+++ b/src/components/admin/settings/branding-form.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"
import { LogoMark } from "@/components/brand/logo"
import { Panel } from "@/components/admin/ui"
import { SettingField, SettingsFormShell, useSettingsForm } from "@/components/admin/settings/form"
+import { useI18n } from "@/lib/i18n"
export type BrandingSettingsValue = {
productName: string
@@ -45,18 +46,20 @@ function LogoPreview({ url, name }: { url: string; name: string }) {
}
export function BrandingSettingsForm({ initial }: { initial: BrandingSettingsValue }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("branding", initial)
const v = form.values
const accent = HEX.test(v.accentColor) ? v.accentColor : undefined
return (
-
+
@@ -95,7 +98,7 @@ export function BrandingSettingsForm({ initial }: { initial: BrandingSettingsVal
className="relative size-8 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-input"
style={{ backgroundColor: accent ?? "var(--brand)" }}
>
- Pick accent color
+ {it ? "Scegli il colore principale" : "Pick accent color"}
{v.accentColor && (
- form.set({ accentColor: "" })}>
+ form.set({ accentColor: "" })}>
)}
-
Preview
+
{it ? "Anteprima" : "Preview"}
- Sign in to {v.productName.trim() || "Dispatch"} with your email
+ {it ? "Accedi a " : "Sign in to "}{v.productName.trim() || "Dispatch"} {it ? "con la tua email" : "with your email"}
- Continue with email
+ {it ? "Continua con l’email" : "Continue with email"}
- 3 new conversations
+ {it ? "3 nuove conversazioni" : "3 new conversations"}
diff --git a/src/components/admin/settings/email-form.tsx b/src/components/admin/settings/email-form.tsx
index b62ec7b..51a16f3 100644
--- a/src/components/admin/settings/email-form.tsx
+++ b/src/components/admin/settings/email-form.tsx
@@ -3,6 +3,7 @@
import { Panel, StatusBadge, type Tone } from "@/components/admin/ui"
import { EmailDeliveryFields, TestEmailPanel, type EmailDeliveryValue } from "@/components/admin/email-delivery-fields"
import { SettingsFormShell, useSettingsForm } from "@/components/admin/settings/form"
+import { useI18n } from "@/lib/i18n"
export type EmailSettingsValue = EmailDeliveryValue & { passwordEnc: string }
@@ -17,6 +18,8 @@ export function EmailSettingsForm({
adminEmail: string
defaultFromEmail: string
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("email", initial)
const { passwordEnc: _preview, ...config } = form.values
void _preview
@@ -24,8 +27,8 @@ export function EmailSettingsForm({
return (
{status.label}}
>
diff --git a/src/components/admin/settings/form.tsx b/src/components/admin/settings/form.tsx
index bb95956..3110df6 100644
--- a/src/components/admin/settings/form.tsx
+++ b/src/components/admin/settings/form.tsx
@@ -11,6 +11,7 @@ import { Spinner } from "@/components/ui/spinner"
import { saveSettingsAction } from "@/app/admin/settings/actions"
import type { EditableSection } from "@/server/admin/settings"
import { cn } from "@/lib/utils"
+import { localizeError, useI18n } from "@/lib/i18n"
/**
* Settings form framework for Admin → Settings.
@@ -58,6 +59,7 @@ export function getPath(obj: unknown, path: string): unknown {
export type SettingsForm = ReturnType>
export function useSettingsForm(section: EditableSection, initial: T) {
+ const { locale } = useI18n()
const router = useRouter()
const initialJson = JSON.stringify(initial)
const [synced, setSynced] = useState(initialJson)
@@ -133,17 +135,17 @@ export function useSettingsForm(section: EditableSection, initia
const res = await saveSettingsAction({ section, values: changes, secrets: secretChanges })
if (!res.ok) {
setErrors(res.fieldErrors ?? {})
- toast.error(res.error)
+ toast.error(localizeError(res.error, locale))
resolve(false)
return
}
reset(res.data as T)
setSynced(JSON.stringify(res.data))
- toast.success("Settings saved")
+ toast.success(locale === "it" ? "Impostazioni salvate" : "Settings saved")
router.refresh()
resolve(true)
} catch {
- toast.error("Could not reach the server. Try again.")
+ toast.error(locale === "it" ? "Impossibile raggiungere il server. Riprova." : "Could not reach the server. Try again.")
resolve(false)
}
})
@@ -209,25 +211,27 @@ export function SettingsSaveBar({
form: { dirty: boolean; saving: boolean; save: () => Promise; discard: () => void }
className?: string
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
if (!form.dirty && !form.saving) return
return (
- Unsaved changes
- ⌘S to save
+ {it ? "Modifiche non salvate" : "Unsaved changes"}
+ {it ? "⌘S per salvare" : "⌘S to save"}
- Discard
+ {it ? "Annulla" : "Discard"}
{form.saving && }
- Save changes
+ {it ? "Salva modifiche" : "Save changes"}
diff --git a/src/components/admin/settings/general-form.tsx b/src/components/admin/settings/general-form.tsx
index 733b941..6bf9387 100644
--- a/src/components/admin/settings/general-form.tsx
+++ b/src/components/admin/settings/general-form.tsx
@@ -8,6 +8,7 @@ import { ChoiceCards } from "@/components/admin/choice-cards"
import { SettingField, SettingsFormShell, SwitchField, useSettingsForm } from "@/components/admin/settings/form"
import { TimezoneSelect } from "@/components/admin/settings/timezone-select"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
export type GeneralSettingsValue = {
instanceName: string
@@ -22,12 +23,13 @@ const ANNOUNCEMENT_MAX = 500
export function GeneralSettingsForm({ initial }: { initial: GeneralSettingsValue }) {
const form = useSettingsForm("general", initial)
+ const { t } = useI18n()
const v = form.values
return (
-
+
-
+
form.set({ defaultTimezone })} />
@@ -64,22 +66,22 @@ export function GeneralSettingsForm({ initial }: { initial: GeneralSettingsValue
-
+
form.set({ mode })}
options={[
{
value: "private",
- title: "Private — just my company",
- description: "Self-hosted for your own team. Sign-up is by invitation; no billing.",
+ title: t("genPrivate"),
+ description: t("genPrivateDesc"),
icon: Building2,
},
{
value: "saas",
- title: "Public SaaS — sign-ups & billing",
- description: "Let other teams sign up and create workspaces, optionally with paid Stripe subscriptions.",
+ title: t("genSaas"),
+ description: t("genSaasDesc"),
icon: Globe2,
},
]}
@@ -87,8 +89,8 @@ export function GeneralSettingsForm({ initial }: { initial: GeneralSettingsValue
form.set({ marketingSite })}
/>
@@ -96,16 +98,16 @@ export function GeneralSettingsForm({ initial }: { initial: GeneralSettingsValue
-
+
{v.announcement.trim() && (
-
Preview
+
{t("genPreview")}
{v.announcement.trim()}
diff --git a/src/components/admin/settings/legal-form.tsx b/src/components/admin/settings/legal-form.tsx
index 72af6fb..5ab5e38 100644
--- a/src/components/admin/settings/legal-form.tsx
+++ b/src/components/admin/settings/legal-form.tsx
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"
import { Panel } from "@/components/admin/ui"
import { SettingField, SettingsFormShell, useSettingsForm } from "@/components/admin/settings/form"
import { MarkdownEditor } from "@/components/admin/settings/markdown-editor"
+import { useI18n } from "@/lib/i18n"
export type LegalSettingsValue = {
companyName: string
@@ -38,12 +39,14 @@ const DOCS = [
] as const
export function LegalSettingsForm({ initial }: { initial: LegalSettingsValue }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("legal", initial)
const v = form.values
return (
-
-
+
+
(
- {doc.description} Published at /legal/{doc.slug}.
+ {it ? ({ imprint: "Informazioni legali: nome dell’azienda, indirizzo, rappresentanti, registrazione e contatti.", privacy: "Quali dati personali vengono trattati, per quali finalità, per quanto tempo, da quali responsabili e quali sono i diritti delle persone.", terms: "L’accordo tra te e le persone che utilizzano questa istanza." } as const)[doc.key] : doc.description}{" "}{it ? "Pubblicata in" : "Published at"} /legal/{doc.slug}.
>
}
actions={
@@ -71,7 +74,7 @@ export function LegalSettingsForm({ initial }: { initial: LegalSettingsValue })
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-sm text-[13px] font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
- View page
+ {it ? "Visualizza pagina" : "View page"}
) : null
}
diff --git a/src/components/admin/settings/markdown-editor.tsx b/src/components/admin/settings/markdown-editor.tsx
index aa6bc7f..1d49389 100644
--- a/src/components/admin/settings/markdown-editor.tsx
+++ b/src/components/admin/settings/markdown-editor.tsx
@@ -5,6 +5,7 @@ import { marked } from "marked"
import DOMPurify from "isomorphic-dompurify"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
/** Typography for rendered markdown (no typography plugin in this project). */
export const MARKDOWN_PROSE =
@@ -29,12 +30,14 @@ export function MarkdownEditor({
placeholder?: string
invalid?: boolean
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [tab, setTab] = useState<"write" | "preview">("write")
const html = useMemo(() => (tab === "preview" ? renderMarkdown(value) : ""), [tab, value])
return (
-
+
{(["write", "preview"] as const).map((t) => (
- {t}
+ {t === "write" ? (it ? "Scrivi" : "Write") : (it ? "Anteprima" : "Preview")}
))}
@@ -69,7 +72,7 @@ export function MarkdownEditor({
{value.trim() ? (
) : (
-
Nothing to preview yet.
+
{it ? "Non c’è ancora nulla da visualizzare." : "Nothing to preview yet."}
)}
)}
diff --git a/src/components/admin/settings/oauth-form.tsx b/src/components/admin/settings/oauth-form.tsx
index fc75c56..86035c7 100644
--- a/src/components/admin/settings/oauth-form.tsx
+++ b/src/components/admin/settings/oauth-form.tsx
@@ -6,6 +6,7 @@ import { Panel, StatusBadge } from "@/components/admin/ui"
import { CopyField } from "@/components/admin/client"
import { SecretInput } from "@/components/admin/secret-input"
import { SettingField, SettingsFormShell, SwitchField, useSettingsForm } from "@/components/admin/settings/form"
+import { useI18n } from "@/lib/i18n"
export type OAuthSettingsValue = {
google: { enabled: boolean; clientId: string; clientSecretEnc: string }
@@ -54,11 +55,11 @@ function ScopeList({ scopes }: { scopes: string[] }) {
)
}
-function statusOf(cfg: { enabled: boolean; clientId: string; clientSecretEnc: string }) {
+function statusOf(cfg: { enabled: boolean; clientId: string; clientSecretEnc: string }, it: boolean) {
const complete = Boolean(cfg.clientId && cfg.clientSecretEnc)
- if (cfg.enabled && complete) return
Active
- if (complete) return
Configured · disabled
- return
Not configured
+ if (cfg.enabled && complete) return
{it ? "Attivo" : "Active"}
+ if (complete) return
{it ? "Configurato · disattivato" : "Configured · disabled"}
+ return
{it ? "Non configurato" : "Not configured"}
}
export function OAuthSettingsForm({
@@ -70,6 +71,8 @@ export function OAuthSettingsForm({
google: ProviderInfo
microsoft: ProviderInfo
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("oauth", initial)
const g = form.values.google
const m = form.values.microsoft
@@ -77,19 +80,19 @@ export function OAuthSettingsForm({
form.setIn("google.enabled", enabled)}
/>
-
+
form.setIn("google.clientId", e.target.value)}
/>
-
+
-
+
-
Setup · Google Cloud
+
{it ? "Configurazione · Google Cloud" : "Setup · Google Cloud"}
- Create or pick a project in the Google Cloud console .
+ {it ? "Crea o seleziona un progetto nella " : "Create or pick a project in the "}{it ? "console Google Cloud" : "Google Cloud console"} .
- Enable the Gmail API .
+ {it ? "Attiva l’" : "Enable the "}API Gmail .
- Configure the OAuth consent screen and add
- the scopes:
+ {it ? "Configura la " : "Configure the "}{it ? "schermata di consenso OAuth" : "OAuth consent screen"} {it ? "e aggiungi gli ambiti:" : "and add the scopes:"}
- https://mail.google.com/ is a restricted scope: internal (Workspace-only)
- apps work right away, public apps need Google's verification.
+ https://mail.google.com/{it ? " è un ambito soggetto a restrizioni: le app interne (solo Workspace) funzionano subito, quelle pubbliche richiedono la verifica di Google." : " is a restricted scope: internal (Workspace-only) apps work right away, public apps need Google's verification."}
- Create an OAuth client ID of type{" "}
- Web application with the redirect URI shown here, then paste the client ID and secret.
+ {it ? "Crea un " : "Create an "}{it ? "ID client OAuth" : "OAuth client ID"} {it ? "di tipo " : "of type "}{it ? "Applicazione web" : "Web application"} {it ? " con l’URI di reindirizzamento mostrato qui, poi incolla l’ID client e il segreto." : " with the redirect URI shown here, then paste the client ID and secret."}
@@ -143,21 +143,21 @@ export function OAuthSettingsForm({
form.setIn("microsoft.enabled", enabled)}
/>
-
+
form.setIn("microsoft.clientId", e.target.value)}
/>
-
+
form.setSecret("microsoft.clientSecretEnc", v)}
- placeholder="Secret value (not the secret ID)"
+ placeholder={it ? "Valore del segreto (non l’ID del segreto)" : "Secret value (not the secret ID)"}
/>
- common allows work, school and personal accounts;{" "}
- organizations only work/school accounts; or enter your tenant ID or domain
- to restrict sign-in to your organization.
+ common {it ? "consente account aziendali, scolastici e personali; " : "allows work, school and personal accounts; "}
+ organizations {it ? "solo account aziendali o scolastici; oppure inserisci l’ID tenant o il dominio per limitare l’accesso alla tua organizzazione." : "only work/school accounts; or enter your tenant ID or domain to restrict sign-in to your organization."}
>
}
error={form.error("microsoft.tenant")}
@@ -201,34 +200,32 @@ export function OAuthSettingsForm({
aria-invalid={Boolean(form.error("microsoft.tenant"))}
/>
-
+
-
Setup · Microsoft Entra ID
+
{it ? "Configurazione · Microsoft Entra ID" : "Setup · Microsoft Entra ID"}
- Open{" "}
- App registrations {" "}
- and create a new registration. For common , choose “Accounts in any organizational directory and personal Microsoft
- accounts”.
+ {it ? "Apri " : "Open "}
+ {it ? "Registrazioni app" : "App registrations"} {" "}
+ {it ? "e crea una nuova registrazione. Per " : "and create a new registration. For "}common {it ? ", scegli “Account in qualsiasi directory organizzativa e account Microsoft personali”." : ", choose “Accounts in any organizational directory and personal Microsoft accounts”."}
- Under Authentication , add a Web platform with the redirect URI shown here.
+ {it ? "In " : "Under "}{it ? "Autenticazione" : "Authentication"} {it ? ", aggiungi una piattaforma " : ", add a "}Web {it ? " con l’URI di reindirizzamento mostrato qui." : " platform with the redirect URI shown here."}
- Under API permissions , add these delegated permissions:
+ {it ? "In " : "Under "}{it ? "Autorizzazioni API" : "API permissions"} {it ? ", aggiungi queste autorizzazioni delegate:" : ", add these delegated permissions:"}
- Under Certificates & secrets , create a client secret and paste its value together with the Application
- (client) ID. Note the expiry date — renew it before it lapses.
+ {it ? "In " : "Under "}{it ? "Certificati e segreti" : "Certificates & secrets"} {it ? ", crea un segreto client e incollane il " : ", create a client secret and paste its "}{it ? "valore" : "value"} {it ? " insieme all’ID applicazione (client). Prendi nota della scadenza e rinnovalo prima che scada." : " together with the Application (client) ID. Note the expiry date — renew it before it lapses."}
- For Sign in with Microsoft : under Token configuration , add the optional ID-token claims{" "}
+ {it ? "Per " : "For "}Sign in with Microsoft {it ? ": in " : ": under "}{it ? "Configurazione token" : "Token configuration"} {it ? ", aggiungi le attestazioni facoltative del token ID" : ", add the optional ID-token claims"}{" "}
email and xms_edov (verified-domain
- flag). Work accounts without them can't sign in the first time.
+ {it ? " (indicatore del dominio verificato). Gli account aziendali senza queste attestazioni non potranno accedere la prima volta." : " (verified-domain flag). Work accounts without them can't sign in the first time."}
diff --git a/src/components/admin/settings/security-form.tsx b/src/components/admin/settings/security-form.tsx
index 05f0c46..5ffc956 100644
--- a/src/components/admin/settings/security-form.tsx
+++ b/src/components/admin/settings/security-form.tsx
@@ -4,6 +4,7 @@ import { Eye, EyeOff, ImageIcon } from "lucide-react"
import { Panel } from "@/components/admin/ui"
import { ChoiceCards } from "@/components/admin/choice-cards"
import { NumberInput, SettingField, SettingsFormShell, SwitchField, useSettingsForm } from "@/components/admin/settings/form"
+import { useI18n } from "@/lib/i18n"
export type SecuritySettingsValue = {
blockPrivateNetworks: boolean
@@ -14,19 +15,20 @@ export type SecuritySettingsValue = {
export function SecuritySettingsForm({ initial, mode }: { initial: SecuritySettingsValue; mode: "private" | "saas" }) {
const form = useSettingsForm("security", initial)
+ const { t } = useI18n()
const v = form.values
return (
-
+
- Refuse IMAP, SMTP and webhook connections to private, loopback and link-local addresses (SSRF protection).{" "}
+ {t("secBlockPrivateDesc")} {" "}
{mode === "saas"
- ? "Always on for public SaaS instances — workspaces run by strangers must never reach your internal network."
- : "Recommended unless your team connects to mail servers on your internal network."}
+ ? t("secSaasAlways")
+ : t("secPrivateRecommended")}
>
}
// Enforced by the mail engine in SaaS mode regardless of the stored value (src/server/mail/net-guard.ts)
@@ -35,17 +37,16 @@ export function SecuritySettingsForm({ initial, mode }: { initial: SecuritySetti
onCheckedChange={(blockPrivateNetworks) => form.set({ blockPrivateNetworks })}
/>
- Plain-text IMAP/SMTP without STARTTLS is only allowed for servers on private or loopback addresses; public mail servers
- must support TLS.
+ {t("secPlainTextWarning")}
-
+
form.set({ loginRateLimitPerHour })}
aria-invalid={Boolean(form.error("loginRateLimitPerHour"))}
/>
form.set({ maxSessionsPerUser })}
aria-invalid={Boolean(form.error("maxSessionsPerUser"))}
/>
@@ -78,18 +79,18 @@ export function SecuritySettingsForm({ initial, mode }: { initial: SecuritySetti
form.set({ remoteImages })}
options={[
- { value: "ask", title: "Ask", description: "Block until someone chooses to load them.", icon: ImageIcon, badge: "Default" },
- { value: "always", title: "Always load", description: "Most convenient; senders can track opens.", icon: Eye },
- { value: "never", title: "Never load", description: "Maximum privacy; images stay hidden.", icon: EyeOff },
+ { value: "ask", title: t("secAsk"), description: t("secAskDesc"), icon: ImageIcon, badge: t("secDefault") },
+ { value: "always", title: t("secAlwaysLoad"), description: t("secAlwaysLoadDesc"), icon: Eye },
+ { value: "never", title: t("secNeverLoad"), description: t("secNeverLoadDesc"), icon: EyeOff },
]}
/>
diff --git a/src/components/admin/settings/storage-form.tsx b/src/components/admin/settings/storage-form.tsx
index 1538f83..5bc4ade 100644
--- a/src/components/admin/settings/storage-form.tsx
+++ b/src/components/admin/settings/storage-form.tsx
@@ -19,6 +19,7 @@ import {
useSettingsForm,
} from "@/components/admin/settings/form"
import { testStorageAction } from "@/app/admin/settings/actions"
+import { useI18n } from "@/lib/i18n"
export type StorageSettingsValue = {
driver: "local" | "s3"
@@ -32,6 +33,8 @@ export type StorageSettingsValue = {
}
export function StorageSettingsForm({ initial, localPath }: { initial: StorageSettingsValue; localPath: string }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const form = useSettingsForm("storage", initial)
const v = form.values
const [testing, setTesting] = useState(false)
@@ -45,7 +48,7 @@ export function StorageSettingsForm({ initial, localPath }: { initial: StorageSe
const res = await testStorageAction({})
setResult(res.ok ? { ok: true, message: res.data.message } : { ok: false, message: res.error })
} catch {
- setResult({ ok: false, message: "Could not reach the server." })
+ setResult({ ok: false, message: it ? "Impossibile raggiungere il server." : "Could not reach the server." })
} finally {
setTesting(false)
}
@@ -54,24 +57,24 @@ export function StorageSettingsForm({ initial, localPath }: { initial: StorageSe
return (
- {form.baseline.driver === "s3" ? `S3 · ${form.baseline.s3Bucket || "no bucket"}` : "Local disk"}
+ {form.baseline.driver === "s3" ? `S3 · ${form.baseline.s3Bucket || (it ? "nessun bucket" : "no bucket")}` : it ? "Disco locale" : "Local disk"}
}
>
form.set({ driver })}
options={[
- { value: "local", title: "Local disk", description: "Files live in the data volume. Simple and fast for single-server installs.", icon: HardDrive },
+ { value: "local", title: it ? "Disco locale" : "Local disk", description: it ? "I file sono nel volume dati. Semplice e veloce per installazioni su un singolo server." : "Files live in the data volume. Simple and fast for single-server installs.", icon: HardDrive },
{
value: "s3",
- title: "S3-compatible bucket",
- description: "AWS S3, Cloudflare R2, Hetzner, Backblaze B2, MinIO … Scales independently of the server.",
+ title: it ? "Bucket compatibile con S3" : "S3-compatible bucket",
+ description: it ? "AWS S3, Cloudflare R2, Hetzner, Backblaze B2, MinIO … Cresce indipendentemente dal server." : "AWS S3, Cloudflare R2, Hetzner, Backblaze B2, MinIO … Scales independently of the server.",
icon: Cloud,
},
]}
@@ -79,25 +82,24 @@ export function StorageSettingsForm({ initial, localPath }: { initial: StorageSe
{driverChanged && (
- Switching storage doesn't move existing files. Attachments stored so far stay in the previous location and won't load
- until you copy them over (same keys) — switch before storing important data.
+ {it ? "Cambiare archiviazione non sposta i file esistenti. Gli allegati già salvati restano nella posizione precedente e non saranno accessibili finché non li copi (mantenendo le stesse chiavi). Cambia prima di archiviare dati importanti." : "Switching storage doesn't move existing files. Attachments stored so far stay in the previous location and won't load until you copy them over (same keys) — switch before storing important data."}
)}
{v.driver === "local" ? (
-
Storage path
+
{it ? "Percorso di archiviazione" : "Storage path"}
{localPath}
-
Make sure this directory is on a persistent volume and included in your backups.
+
{it ? "Assicurati che questa cartella si trovi su un volume persistente e sia inclusa nei backup." : "Make sure this directory is on a persistent volume and included in your backups."}
) : (
.r2.cloudflarestorage.com" : "Leave empty for AWS S3. R2: https://.r2.cloudflarestorage.com"}
error={form.error("s3Endpoint")}
className={`${GRID_FIELD} sm:col-span-2`}
>
@@ -121,7 +123,7 @@ export function StorageSettingsForm({ initial, localPath }: { initial: StorageSe
onChange={(e) => form.set({ s3Bucket: e.target.value })}
/>
-
+
form.set({ s3AccessKeyId: e.target.value })}
/>
-
+
form.set({ s3ForcePathStyle })}
/>
@@ -163,11 +165,11 @@ export function StorageSettingsForm({ initial, localPath }: { initial: StorageSe
)}
-
+
-
+
- {testing ? : } Run storage test
+ {testing ? : } {it ? "Prova archiviazione" : "Run storage test"}
- {form.dirty && Save changes to test them. }
+ {form.dirty && {it ? "Salva le modifiche per provarle." : "Save changes to test them."} }
diff --git a/src/components/admin/settings/timezone-select.tsx b/src/components/admin/settings/timezone-select.tsx
index 66ac81e..8c3397c 100644
--- a/src/components/admin/settings/timezone-select.tsx
+++ b/src/components/admin/settings/timezone-select.tsx
@@ -5,6 +5,7 @@ import { ChevronsUpDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { useI18n } from "@/lib/i18n"
function listTimeZones(): string[] {
let zones: string[] = []
@@ -29,6 +30,8 @@ function offsetLabel(tz: string) {
/** Searchable IANA time zone picker. */
export function TimezoneSelect({ id, value, onChange }: { id?: string; value: string; onChange: (tz: string) => void }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [open, setOpen] = useState(false)
const zones = useMemo(() => listTimeZones().map((tz) => ({ tz, offset: offsetLabel(tz) })), [])
return (
@@ -48,9 +51,9 @@ export function TimezoneSelect({ id, value, onChange }: { id?: string; value: st
-
+
- No time zone found.
+ {it ? "Nessun fuso orario trovato." : "No time zone found."}
{zones.map(({ tz, offset }) => (
startTransition(() => router.refresh())} disabled={pending}>
- {pending ? : } Re-run checks
+ {pending ? : } {locale === "it" ? "Ripeti controlli" : "Re-run checks"}
)
}
@@ -33,21 +35,25 @@ export type FailedJobView = {
}
export function RetryAllButton({ count }: { count: number }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
return (
retryAllFailedJobsAction({})}
- success="Failed jobs queued for retry"
+ success={it ? "Attività non riuscite messe in coda per un nuovo tentativo" : "Failed jobs queued for retry"}
>
- Retry all
+ {it ? "Riprova tutte" : "Retry all"}
)
}
function JobRow({ job }: { job: FailedJobView }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [open, setOpen] = useState(false)
const { pending, run } = useAdminAction()
return (
@@ -66,14 +72,14 @@ function JobRow({ job }: { job: FailedJobView }) {
{job.type}
- {job.attempts}/{job.maxAttempts} attempts
+ {job.attempts}/{job.maxAttempts} {it ? "tentativi" : "attempts"}
- · created
+ · {it ? "creata" : "created"}
- {job.lastError ?? "No error recorded"}
+ {job.lastError ?? (it ? "Nessun errore registrato" : "No error recorded")}
@@ -84,27 +90,27 @@ function JobRow({ job }: { job: FailedJobView }) {
disabled={pending}
onClick={() =>
run(() => retryJobAction({ id: job.id }), {
- success: `Retrying ${job.type}`,
+ success: it ? `Nuovo tentativo di ${job.type}` : `Retrying ${job.type}`,
})
}
>
- {pending ? : } Retry
+ {pending ? : } {it ? "Riprova" : "Retry"}
- The {job.type} job is removed permanently and won't run again.
+ {it ? <>L’attività {job.type} verrà eliminata definitivamente e non verrà più eseguita.> : <>The {job.type} job is removed permanently and won't run again.>}
>
}
- confirmLabel="Delete job"
+ confirmLabel={it ? "Elimina attività" : "Delete job"}
destructive
variant="ghost"
action={() => deleteJobAction({ id: job.id })}
- success="Job deleted"
+ success={it ? "Attività eliminata" : "Job deleted"}
>
- Delete
+ {it ? "Elimina" : "Delete"}
@@ -112,10 +118,10 @@ function JobRow({ job }: { job: FailedJobView }) {
- Created
+ {it ? "Creata" : "Created"}
- Last scheduled
+ {it ? "Ultima programmazione" : "Last scheduled"}
{job.id}
diff --git a/src/components/admin/users/credentials-control.tsx b/src/components/admin/users/credentials-control.tsx
new file mode 100644
index 0000000..13d4c97
--- /dev/null
+++ b/src/components/admin/users/credentials-control.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import { useState } from "react"
+import { Save } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { PasswordInput } from "@/components/ui/password-input"
+import { Label } from "@/components/ui/label"
+import { useAdminAction } from "@/components/admin/client"
+import { updateUserCredentialsAction } from "@/app/admin/users/actions"
+import { useI18n } from "@/lib/i18n"
+
+export function CredentialsControl({ id, email }: { id: string; email: string }) {
+ const { t } = useI18n()
+ const [nextEmail, setNextEmail] = useState(email)
+ const [password, setPassword] = useState("")
+ const action = useAdminAction()
+ return (
+
+ )
+}
diff --git a/src/components/admin/users/user-controls.tsx b/src/components/admin/users/user-controls.tsx
index dfdc83a..3a33668 100644
--- a/src/components/admin/users/user-controls.tsx
+++ b/src/components/admin/users/user-controls.tsx
@@ -14,6 +14,7 @@ import {
setSuperAdminAction,
setUserStatusAction,
} from "@/app/admin/users/actions"
+import { useI18n } from "@/lib/i18n"
function Blocked({ children }: { children: React.ReactNode }) {
return
{children}
@@ -30,20 +31,21 @@ export function SuperAdminControl({
isSuperAdmin: boolean
blockedReason: string | null
}) {
+ const { t } = useI18n()
if (isSuperAdmin) {
return (
setSuperAdminAction({ id, value: false })}
- success="Super admin access revoked"
+ success={t("usrSuperRevoked")}
className="w-full justify-start"
>
- Revoke super admin
+ {t("usrRevokeSuper")}
{blockedReason && {blockedReason} }
@@ -53,19 +55,18 @@ export function SuperAdminControl({
- {email} will be able to see every workspace and user, change instance settings, and
- impersonate other people. Only grant this to people who operate the instance.
+ {email} {t("usrGrantSuperDesc")}
>
}
- confirmLabel="Grant access"
+ confirmLabel={t("usrGrantAccess")}
action={() => setSuperAdminAction({ id, value: true })}
- success="Super admin access granted"
+ success={t("usrSuperGranted")}
className="w-full justify-start"
>
- Make super admin
+ {t("usrMakeSuper")}
{blockedReason && {blockedReason} }
@@ -83,17 +84,18 @@ export function AccountStatusControl({
status: "active" | "disabled"
blockedReason: string | null
}) {
+ const { t } = useI18n()
if (status === "disabled") {
return (
setUserStatusAction({ id, status: "active" })}
- success="Account enabled"
+ success={t("usrEnabled")}
className="w-full justify-start"
>
- Enable account
+ {t("usrEnableAccount")}
)
}
@@ -102,14 +104,14 @@ export function AccountStatusControl({
setUserStatusAction({ id, status: "disabled" })}
- success="Account disabled"
+ success={t("usrDisabled")}
className="w-full justify-start"
>
- Disable account
+ {t("usrDisableAccount")}
{blockedReason &&
{blockedReason} }
@@ -129,6 +131,7 @@ export type SessionItem = {
export function SessionsList({ userId, sessions, isSelf }: { userId: string; sessions: SessionItem[]; isSelf: boolean }) {
const revoke = useAdminAction()
+ const { t } = useI18n()
const revocable = sessions.filter((s) => !s.current)
return (
@@ -145,17 +148,17 @@ export function SessionsList({ userId, sessions, isSelf }: { userId: string; ses
{s.deviceLabel}
{s.current && (
- This device
+ {t("usrThisDevice")}
)}
{s.impersonation && (
- Impersonation
+ {t("usrImpersonation")}
)}
- {s.ip ?? "Unknown IP"} · active · signed in{" "}
+ {s.ip ?? t("usrUnknownIp")} · {t("usrActive")} · {t("usrSignedIn")} {" "}
@@ -164,10 +167,10 @@ export function SessionsList({ userId, sessions, isSelf }: { userId: string; ses
variant="ghost"
size="sm"
disabled={revoke.pending}
- aria-label={`Revoke session on ${s.deviceLabel}`}
- onClick={() => revoke.run(() => revokeUserSessionAction({ userId, sessionId: s.id }), { success: "Session revoked" })}
+ aria-label={`${t("usrRevokeSession")} ${s.deviceLabel}`}
+ onClick={() => revoke.run(() => revokeUserSessionAction({ userId, sessionId: s.id }), { success: t("usrSessionRevoked") })}
>
- Revoke
+ {t("usrRevokeSession")}
)}
@@ -178,17 +181,17 @@ export function SessionsList({ userId, sessions, isSelf }: { userId: string; ses
revokeAllUserSessionsAction({ userId })}
- success="Sessions revoked"
+ success={t("usrSessionRevoked")}
>
- {isSelf ? "Sign out other devices" : "Revoke all"}
+ {isSelf ? t("usrSignOutOther") : t("usrRevokeAll")}
)}
@@ -208,21 +211,22 @@ export function DeleteUserControl({
soleOwned: { id: string; name: string }[]
}) {
const router = useRouter()
+ const { t } = useI18n()
return (
deleteUserAction({ id, confirm: email })}
- success="User deleted"
+ success={t("usrDeleted")}
onSuccess={() => router.push("/admin/users")}
className="w-full justify-start sm:w-auto"
>
- Delete user
+ {t("usrDeleteUser")}
{blockedReason && (
diff --git a/src/components/admin/workspaces/workspace-controls.tsx b/src/components/admin/workspaces/workspace-controls.tsx
index d622b9f..a5e0480 100644
--- a/src/components/admin/workspaces/workspace-controls.tsx
+++ b/src/components/admin/workspaces/workspace-controls.tsx
@@ -31,6 +31,7 @@ import {
transferWorkspaceOwnershipAction,
unsuspendWorkspaceAction,
} from "@/app/admin/workspaces/actions"
+import { useI18n } from "@/lib/i18n"
function Row({ label, htmlFor, children, hint }: { label: string; htmlFor?: string; children: React.ReactNode; hint?: React.ReactNode }) {
return (
@@ -65,6 +66,8 @@ export function SubscriptionControls({
stripeSubscriptionId: string | null
currentPeriodEndsAt: string | null
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [nextPlan, setNextPlan] = useState
(plan)
const [nextStatus, setNextStatus] = useState(status)
const [days, setDays] = useState("14")
@@ -74,7 +77,7 @@ export function SubscriptionControls({
return (
-
+
setNextPlan(v as PlanValue)}>
@@ -91,14 +94,14 @@ export function SubscriptionControls({
planAction.run(() => setWorkspacePlanAction({ id, plan: nextPlan }), { success: "Plan updated" })}
+ onClick={() => planAction.run(() => setWorkspacePlanAction({ id, plan: nextPlan }), { success: it ? "Piano aggiornato" : "Plan updated" })}
>
- {planAction.pending && } Save
+ {planAction.pending && } {it ? "Salva" : "Save"}
-
+
setNextStatus(v as SubscriptionStatusValue)}>
@@ -116,24 +119,24 @@ export function SubscriptionControls({
variant="outline"
disabled={nextStatus === status || statusAction.pending}
onClick={() =>
- statusAction.run(() => setWorkspaceStatusAction({ id, status: nextStatus }), { success: "Subscription status updated" })
+ statusAction.run(() => setWorkspaceStatusAction({ id, status: nextStatus }), { success: it ? "Stato abbonamento aggiornato" : "Subscription status updated" })
}
>
- {statusAction.pending && } Save
+ {statusAction.pending && } {it ? "Salva" : "Save"}
- {trialExpired ? "Ended" : "Ends"} . Extending starts from {trialExpired ? "today" : "the current end date"}.
+ {trialExpired ? (it ? "Terminato" : "Ended") : (it ? "Termina" : "Ends")} . {it ? "Estendendo, il periodo partirà da" : "Extending starts from"} {trialExpired ? (it ? "oggi" : "today") : (it ? "dalla data di fine attuale" : "the current end date")}.
>
) : (
- "No trial. Extending starts a trial from today."
+ (it ? "Nessun periodo di prova. L’estensione ne avvierà uno da oggi." : "No trial. Extending starts a trial from today.")
)
}
>
@@ -146,18 +149,18 @@ export function SubscriptionControls({
onChange={(e) => setDays(e.target.value.replace(/\D/g, "").slice(0, 3))}
className="pr-12"
/>
- days
+ {it ? "giorni" : "days"}
trialAction.run(() => extendWorkspaceTrialAction({ id, days: Number(days) }), {
- success: (d) => `Trial extended to ${formatLocalTime(d.trialEndsAt, "date")}`,
+ success: (d) => it ? `Periodo di prova esteso fino al ${formatLocalTime(d.trialEndsAt, "date")}` : `Trial extended to ${formatLocalTime(d.trialEndsAt, "date")}`,
})
}
>
- {trialAction.pending ? : } Extend
+ {trialAction.pending ? : } {it ? "Estendi" : "Extend"}
@@ -170,19 +173,19 @@ export function SubscriptionControls({
{stripeSubscriptionId &&
}
{currentPeriodEndsAt && (
- Current period ends .
+ {it ? "Il periodo attuale termina il" : "Current period ends"} .
)}
{stripeCustomerUrl && (
- Open customer in Stripe
+ {it ? "Apri cliente in Stripe" : "Open customer in Stripe"}
)}
>
) : (
-
No Stripe customer linked to this workspace.
+
{it ? "Nessun cliente Stripe collegato a questo workspace." : "No Stripe customer linked to this workspace."}
)}
@@ -190,6 +193,8 @@ export function SubscriptionControls({
}
export function SuspendControl({ id, name, suspended }: { id: string; name: string; suspended: boolean }) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const [open, setOpen] = useState(false)
const [reason, setReason] = useState("")
const { pending, run } = useAdminAction()
@@ -197,14 +202,14 @@ export function SuspendControl({ id, name, suspended }: { id: string; name: stri
if (suspended) {
return (
unsuspendWorkspaceAction({ id })}
- success="Workspace unsuspended"
+ success={it ? "Workspace riattivato" : "Workspace unsuspended"}
className="w-full sm:w-auto"
>
- Unsuspend workspace
+ {it ? "Riattiva workspace" : "Unsuspend workspace"}
)
}
@@ -219,26 +224,26 @@ export function SuspendControl({ id, name, suspended }: { id: string; name: stri
>
- Suspend workspace
+ {it ? "Sospendi workspace" : "Suspend workspace"}
- Suspend {name}?
+ {it ? `Sospendere ${name}?` : `Suspend ${name}?`}
- The workspace becomes read-only for all members and shows your reason in a banner. Mail sync continues. You can unsuspend at any time.
+ {it ? "Il workspace diventa di sola lettura per tutti i membri e mostra il motivo in un avviso. La sincronizzazione della posta continua. Puoi riattivarlo in qualsiasi momento." : "The workspace becomes read-only for all members and shows your reason in a banner. Mail sync continues. You can unsuspend at any time."}
- Reason shown to members
+ {it ? "Motivo mostrato ai membri" : "Reason shown to members"}
@@ -336,11 +339,11 @@ export function AiAssist({
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
- toast.error("Couldn’t copy to the clipboard")
+ toast.error(tx("Couldn’t copy to the clipboard", "Impossibile copiare negli appunti"))
}
}}
className="ml-auto flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
- aria-label="Copy text"
+ aria-label={tx("Copy text", "Copia testo")}
>
{copied ? : }
@@ -354,22 +357,22 @@ export function AiAssist({
size="sm"
variant="ghost"
onClick={() => lastRequest.current && run(lastRequest.current)}
- aria-label="Regenerate"
- title="Regenerate"
+ aria-label={tx("Regenerate", "Rigenera")}
+ title={tx("Regenerate", "Rigenera")}
>
setView({ name: "menu" })}>
- Back
+ {tx("Back", "Indietro")}
{onReplace && (
apply(view.result.html, "replace")}>
- Replace draft
+ {tx("Replace draft", "Sostituisci bozza")}
)}
apply(view.result.html, "insert")}>
- Insert
+ {tx("Insert", "Inserisci")}
@@ -408,13 +411,14 @@ function MenuItem({
}
function SubHeader({ title, onBack }: { title: string; onBack: () => void }) {
+ const { locale } = useI18n()
return (
diff --git a/src/components/ai/ai-summary.tsx b/src/components/ai/ai-summary.tsx
index 953104a..337e079 100644
--- a/src/components/ai/ai-summary.tsx
+++ b/src/components/ai/ai-summary.tsx
@@ -7,12 +7,13 @@ import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { aiRequest, useAiStatus, type AiSummaryResult } from "./use-ai"
+import { useI18n } from "@/lib/i18n"
-const SENTIMENT: Record
, { label: string; className: string }> = {
- positive: { label: "Positive", className: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" },
- neutral: { label: "Neutral", className: "bg-muted text-muted-foreground" },
- negative: { label: "Frustrated", className: "bg-orange-500/10 text-orange-700 dark:text-orange-300" },
- urgent: { label: "Urgent", className: "bg-red-500/10 text-red-700 dark:text-red-300" },
+const SENTIMENT: Record, { label: string; italian: string; className: string }> = {
+ positive: { label: "Positive", italian: "Positivo", className: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" },
+ neutral: { label: "Neutral", italian: "Neutro", className: "bg-muted text-muted-foreground" },
+ negative: { label: "Frustrated", italian: "Frustrato", className: "bg-orange-500/10 text-orange-700 dark:text-orange-300" },
+ urgent: { label: "Urgent", italian: "Urgente", className: "bg-red-500/10 text-red-700 dark:text-red-300" },
}
/**
@@ -42,6 +43,8 @@ function AiSummaryInner({
className?: string
compact?: boolean
}) {
+ const { locale } = useI18n()
+ const it = locale === "it"
const { data: status } = useAiStatus(slug)
const [open, setOpen] = useState(false)
const summarize = useMutation({
@@ -65,10 +68,10 @@ function AiSummaryInner({
setOpen(true)
summarize.mutate(false)
}}
- aria-label="Summarize conversation"
+ aria-label={it ? "Riassumi la conversazione" : "Summarize conversation"}
>
- {!compact && "Summarize"}
+ {!compact && (it ? "Riassumi" : "Summarize")}
)
}
@@ -76,7 +79,7 @@ function AiSummaryInner({
return (
@@ -85,7 +88,7 @@ function AiSummaryInner({
TL;DR
{summary?.sentiment && (
- {SENTIMENT[summary.sentiment].label}
+ {it ? SENTIMENT[summary.sentiment].italian : SENTIMENT[summary.sentiment].label}
)}
@@ -95,12 +98,12 @@ function AiSummaryInner({
size="icon-xs"
onClick={() => summarize.mutate(true)}
disabled={summarize.isPending}
- aria-label="Regenerate summary"
- title="Regenerate"
+ aria-label={it ? "Rigenera riepilogo" : "Regenerate summary"}
+ title={it ? "Rigenera" : "Regenerate"}
>
- setOpen(false)} aria-label="Close summary">
+ setOpen(false)} aria-label={it ? "Chiudi riepilogo" : "Close summary"}>
@@ -117,7 +120,7 @@ function AiSummaryInner({
{summarize.error.message}
summarize.mutate(false)}>
- Retry
+ {it ? "Riprova" : "Retry"}
) : summary ? (
@@ -135,7 +138,7 @@ function AiSummaryInner({
)}
{summary.nextSteps.length > 0 && (
-
Next steps
+
{it ? "Prossimi passi" : "Next steps"}
{summary.nextSteps.map((s, i) => (
diff --git a/src/components/analytics/analytics-top-bar.tsx b/src/components/analytics/analytics-top-bar.tsx
index 211d587..437c6f4 100644
--- a/src/components/analytics/analytics-top-bar.tsx
+++ b/src/components/analytics/analytics-top-bar.tsx
@@ -5,10 +5,12 @@ import { ArrowLeft, ChartNoAxesColumn, Settings } from "lucide-react"
import { useOrg } from "@/components/app/org-provider"
import { UserMenu, WorkspaceSwitcher } from "@/components/app/user-menu"
import { Button } from "@/components/ui/button"
+import { useI18n } from "@/lib/i18n"
/** Slim chrome for the analytics area (no mail sidebar here). */
export function AnalyticsTopBar() {
const { org } = useOrg()
+ const { t } = useI18n()
const base = `/w/${org.slug}`
return (
@@ -17,21 +19,21 @@ export function AnalyticsTopBar() {
className="group flex shrink-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
- Back to inbox
- Inbox
+ {t("backToInbox")}
+ {t("inbox")}
/
- Analytics
+ {t("analytics")}
- Settings
+ {t("workspaceSettings")}
diff --git a/src/components/analytics/analytics-view.tsx b/src/components/analytics/analytics-view.tsx
index 6f78e61..349cd06 100644
--- a/src/components/analytics/analytics-view.tsx
+++ b/src/components/analytics/analytics-view.tsx
@@ -14,6 +14,7 @@ import { UserAvatar } from "@/components/app/user-avatar"
import { useIsMobile } from "@/hooks/use-mobile"
import { addDaysYmd, formatRangeLabel, parseYmd, toYmd } from "@/components/analytics/format"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
export type AnalyticsFilterState = {
range: "7" | "30" | "90" | "custom"
@@ -61,6 +62,7 @@ export function AnalyticsView({
today: string
children: React.ReactNode
}) {
+ const { t, locale } = useI18n()
const router = useRouter()
const pathname = usePathname()
const [pending, startTransition] = useTransition()
@@ -103,7 +105,7 @@ export function AnalyticsView({
return (
<>
-
+
{
if (v && v !== "custom") navigate({ range: v as AnalyticsFilterState["range"] })
}}
- aria-label="Date range"
+ aria-label={t("dateRange")}
className="bg-card"
>
{(["7", "30", "90"] as const).map((r) => (
-
- {r}D
+
+ {r}{locale === "it" ? "g" : "D"}
))}
- {formatRangeLabel(filters.from, addDaysYmd(filters.to, 1))}
+ {formatRangeLabel(filters.from, addDaysYmd(filters.to, 1), locale)}
- {pending && }
+ {pending && }
navigate({ inbox: v })}
- placeholder="All inboxes"
- searchPlaceholder="Search inboxes…"
- emptyText="No shared inboxes."
+ placeholder={t("allInboxes")}
+ searchPlaceholder={t("searchInboxes")}
+ emptyText={t("noSharedInboxes")}
clearable
className="bg-card lg:w-44"
/>
@@ -149,9 +151,9 @@ export function AnalyticsView({
options={teamOptions}
value={filters.team}
onChange={(v) => navigate({ team: v })}
- placeholder="All teams"
- searchPlaceholder="Search teams…"
- emptyText="No teams yet."
+ placeholder={t("allTeams")}
+ searchPlaceholder={t("searchTeams")}
+ emptyText={t("noTeamsYet")}
clearable
className="bg-card lg:w-40"
/>
@@ -159,8 +161,8 @@ export function AnalyticsView({
options={memberOptions}
value={filters.user}
onChange={(v) => navigate({ user: v })}
- placeholder="All teammates"
- searchPlaceholder="Search teammates…"
+ placeholder={t("allTeammates")}
+ searchPlaceholder={t("analyticsSearchTeammates")}
clearable
className="bg-card lg:w-48"
/>
@@ -172,7 +174,7 @@ export function AnalyticsView({
className="self-start text-muted-foreground lg:self-auto"
onClick={() => navigate({ range: DEFAULT_RANGE, inbox: null, team: null, user: null })}
>
- Reset
+ {t("reset")}
)}
@@ -196,6 +198,7 @@ function CustomRange({
today: string
onApply: (from: string, to: string) => void
}) {
+ const { t } = useI18n()
const isMobile = useIsMobile()
const [open, setOpen] = useState(false)
const [range, setRange] = useState
({ from: parseYmd(from), to: parseYmd(to) })
@@ -221,7 +224,7 @@ function CustomRange({
)}
>
- Custom
+ {t("analyticsCustom")}
@@ -237,11 +240,11 @@ function CustomRange({
/>
- {range?.from && range.to ? `${days} ${days === 1 ? "day" : "days"}` : "Pick a start and end date"}
+ {range?.from && range.to ? `${days} ${t(days === 1 ? "analyticsDay" : "analyticsDays")}` : t("analyticsPickDateRange")}
setOpen(false)}>
- Cancel
+ {t("cancel")}
- Apply
+ {t("analyticsApply")}
diff --git a/src/components/analytics/busiest-hours.tsx b/src/components/analytics/busiest-hours.tsx
index 732eec2..dc8a810 100644
--- a/src/components/analytics/busiest-hours.tsx
+++ b/src/components/analytics/busiest-hours.tsx
@@ -3,13 +3,16 @@
import { useMemo, useRef, useState } from "react"
import { ChartCard, DataTable } from "@/components/analytics/chart-card"
import { ChartEmpty } from "@/components/analytics/trend-charts"
-import { formatHour, WEEKDAYS } from "@/components/analytics/format"
+import { formatHour, WEEKDAYS, WEEKDAYS_IT } from "@/components/analytics/format"
import { HEAT_STEPS, heatColor } from "@/components/analytics/palette"
import type { HeatmapCell } from "@/server/workspace/queries/analytics"
+import { useI18n } from "@/lib/i18n"
const HOURS = Array.from({ length: 24 }, (_, h) => h)
export function BusiestHours({ cells, timezone }: { cells: HeatmapCell[]; timezone: string }) {
+ const { t, locale } = useI18n()
+ const weekdays = locale === "it" ? WEEKDAYS_IT : WEEKDAYS
const { grid, max, total, peak } = useMemo(() => {
const g = Array.from({ length: 7 }, () => Array(24).fill(0))
let mx = 0
@@ -31,36 +34,36 @@ export function BusiestHours({ cells, timezone }: { cells: HeatmapCell[]; timezo
const wrapRef = useRef(null)
const step = (v: number) => (v <= 0 || max === 0 ? 0 : Math.max(1, Math.ceil((v / max) * HEAT_STEPS.length)))
- const peakText = peak ? `${WEEKDAYS[peak.weekday - 1]} ${formatHour(peak.hour)} (${peak.count.toLocaleString("en-US")} messages)` : ""
+ const peakText = peak ? `${weekdays[peak.weekday - 1]} ${formatHour(peak.hour)} (${peak.count.toLocaleString(locale === "it" ? "it-IT" : "en-US")} ${t("analyticsMessages")})` : ""
return (
({ key: String(h), label: String(h).padStart(2, "0"), align: "right" as const }))]}
+ caption={t("incomingByWeekday")}
+ columns={[{ key: "day", label: t("analyticsDayLabel") }, ...HOURS.map((h) => ({ key: String(h), label: String(h).padStart(2, "0"), align: "right" as const }))]}
rows={grid.map((row, d) => ({
- day: WEEKDAYS[d]!.slice(0, 3),
+ day: weekdays[d]!.slice(0, 3),
...Object.fromEntries(row.map((v, h) => [String(h), v || ""])),
}))}
/>
}
>
{total === 0 ? (
- No incoming emails in this period.
+ {t("noIncomingEmails")}
) : (
setHover(null)}>
{grid.map((row, d) => (
-
{WEEKDAYS[d]!.slice(0, 3)}
+
{weekdays[d]!.slice(0, 3)}
{row.map((v, h) => (
- {grid[hover.day]![hover.hour]!.toLocaleString("en-US")} {grid[hover.day]![hover.hour] === 1 ? "message" : "messages"}
+ {grid[hover.day]![hover.hour]!.toLocaleString(locale === "it" ? "it-IT" : "en-US")} {grid[hover.day]![hover.hour] === 1 ? t("analyticsMessage") : t("analyticsMessages")}
- {WEEKDAYS[hover.day]} · {formatHour(hover.hour)}–{formatHour((hover.hour + 1) % 24)}
+ {weekdays[hover.day]} · {formatHour(hover.hour)}–{formatHour((hover.hour + 1) % 24)}
)}
- Busiest: {peakText}
+ {t("analyticsBusiest")} {peakText}
- Less
+ {t("analyticsLess")}
{[0, ...HEAT_STEPS.map((_, i) => i + 1)].map((s) => (
))}
- More
+ {t("analyticsMore")}
diff --git a/src/components/analytics/chart-card.tsx b/src/components/analytics/chart-card.tsx
index d9ff78e..91605df 100644
--- a/src/components/analytics/chart-card.tsx
+++ b/src/components/analytics/chart-card.tsx
@@ -5,6 +5,7 @@ import { useState } from "react"
import { ChartLine, Table2 } from "lucide-react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
/**
* Card for one chart with an optional table view (every chart has a
@@ -29,6 +30,7 @@ export function ChartCard({
className?: string
action?: React.ReactNode
}) {
+ const { t } = useI18n()
const [view, setView] = useState<"chart" | "table">("chart")
return (
@@ -46,12 +48,12 @@ export function ChartCard({
variant="outline"
value={view}
onValueChange={(v) => v && setView(v as "chart" | "table")}
- aria-label="View"
+ aria-label={t("analyticsView")}
>
-
+
-
+
diff --git a/src/components/analytics/format.ts b/src/components/analytics/format.ts
index 9bc7660..46c54e4 100644
--- a/src/components/analytics/format.ts
+++ b/src/components/analytics/format.ts
@@ -16,10 +16,11 @@ export function formatDuration(seconds: number | null | undefined, fallback = "
}
/** Compact integer: 1,284 / 12.9K / 4.2M */
-export function formatCount(n: number | null | undefined, fallback = "—"): string {
+export function formatCount(n: number | null | undefined, fallback = "—", locale: "en" | "it" = "en"): string {
if (n === null || n === undefined || !Number.isFinite(n)) return fallback
- if (Math.abs(n) < 10_000) return n.toLocaleString("en-US")
- return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n)
+ const intlLocale = locale === "it" ? "it-IT" : "en-US"
+ if (Math.abs(n) < 10_000) return n.toLocaleString(intlLocale)
+ return new Intl.NumberFormat(intlLocale, { notation: "compact", maximumFractionDigits: 1 }).format(n)
}
/** Parse YYYY-MM-DD as a local calendar date (no timezone shift). */
@@ -50,21 +51,24 @@ export function addDaysYmd(ymd: string, days: number): string {
return new Date(ymdToUtc(ymd).getTime() + days * 86_400_000).toISOString().slice(0, 10)
}
-const shortDate = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
-const longDate = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
-const weekdayDate = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
-
-export function formatBucket(ymd: string, granularity: "day" | "week", long = false): string {
+export function formatBucket(ymd: string, granularity: "day" | "week", long = false, locale: "en" | "it" = "en"): string {
const d = ymdToUtc(ymd)
- if (granularity === "week") return `Week of ${(long ? longDate : shortDate).format(d)}`
+ const intlLocale = locale === "it" ? "it-IT" : "en-US"
+ const shortDate = new Intl.DateTimeFormat(intlLocale, { month: "short", day: "numeric", timeZone: "UTC" })
+ const longDate = new Intl.DateTimeFormat(intlLocale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
+ const weekdayDate = new Intl.DateTimeFormat(intlLocale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
+ if (granularity === "week") return locale === "it" ? `Settimana del ${(long ? longDate : shortDate).format(d)}` : `Week of ${(long ? longDate : shortDate).format(d)}`
return (long ? weekdayDate : shortDate).format(d)
}
/** "Aug 29 – Sep 27, 2026" for an inclusive-from / exclusive-to range. */
-export function formatRangeLabel(from: string, toExclusive: string): string {
+export function formatRangeLabel(from: string, toExclusive: string, locale: "en" | "it" = "en"): string {
const a = ymdToUtc(from)
const b = ymdToUtc(addDaysYmd(toExclusive, -1))
const sameYear = a.getUTCFullYear() === b.getUTCFullYear()
+ const intlLocale = locale === "it" ? "it-IT" : "en-US"
+ const shortDate = new Intl.DateTimeFormat(intlLocale, { month: "short", day: "numeric", timeZone: "UTC" })
+ const longDate = new Intl.DateTimeFormat(intlLocale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
return `${(sameYear ? shortDate : longDate).format(a)} – ${longDate.format(b)}`
}
@@ -85,6 +89,7 @@ export function computeDelta(current: number | null, previous: number | null, lo
}
export const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const
+export const WEEKDAYS_IT = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"] as const
export function formatHour(h: number): string {
return `${String(h).padStart(2, "0")}:00`
diff --git a/src/components/analytics/kpi-tiles.tsx b/src/components/analytics/kpi-tiles.tsx
index 5bb0335..a62f0a0 100644
--- a/src/components/analytics/kpi-tiles.tsx
+++ b/src/components/analytics/kpi-tiles.tsx
@@ -1,11 +1,15 @@
+"use client"
+
import { ArrowDownRight, ArrowUpRight, Minus, Sparkles } from "lucide-react"
import { MicroLabel } from "@/components/settings/settings-ui"
import { computeDelta, formatCount, formatDuration, type Delta } from "@/components/analytics/format"
import type { AnalyticsKpis, SeriesPoint } from "@/server/workspace/queries/analytics"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
function DeltaChip({ delta, periodLabel }: { delta: Delta | null; periodLabel: string }) {
- if (!delta) return No data to compare
+ const { t, locale } = useI18n()
+ if (!delta) return {t("analyticsNoDataCompare")}
const tone =
delta.good === null
? "bg-muted text-muted-foreground"
@@ -15,21 +19,21 @@ function DeltaChip({ delta, periodLabel }: { delta: Delta | null; periodLabel: s
const Icon = delta.direction === "up" ? ArrowUpRight : delta.direction === "down" ? ArrowDownRight : delta.direction === "new" ? Sparkles : Minus
const text =
delta.direction === "new"
- ? "New"
+ ? t("analyticsNew")
: delta.direction === "flat"
? "0%"
- : `${delta.pct! > 0 ? "+" : "−"}${Math.abs(delta.pct!) >= 100 ? Math.round(Math.abs(delta.pct!)) : Math.abs(delta.pct!).toFixed(1).replace(/\.0$/, "")}%`
- const verdict = delta.good === null ? "" : delta.good ? ", better" : ", worse"
+ : `${delta.pct! > 0 ? "+" : "−"}${new Intl.NumberFormat(locale === "it" ? "it-IT" : "en-US", { maximumFractionDigits: 1 }).format(Math.abs(delta.pct!))}%`
+ const verdict = delta.good === null ? "" : delta.good ? `, ${t("analyticsBetter")}` : `, ${t("analyticsWorse")}`
return (
{text}
- vs {periodLabel}
+ {t("analyticsVs")} {periodLabel}
)
}
@@ -82,26 +86,27 @@ function Tile({
}
export function KpiTiles({ kpis, series, days }: { kpis: AnalyticsKpis; series: SeriesPoint[]; days: number }) {
- const period = `prev. ${days}d`
+ const { t, locale } = useI18n()
+ const period = t("analyticsPrevious").replace("{days}", String(days))
const tail = series.slice(-14)
return (
}
spark={ p.received)} color="var(--viz-in)" />}
- hint="New conversations with at least one incoming email"
+ hint={t("analyticsNoReceived")}
/>
}
spark={ p.replied)} color="var(--viz-out)" />}
- hint="Emails sent by teammates"
+ hint={t("analyticsSentByTeammates")}
/>
}
spark={ p.medianFirstResponse)} color="var(--viz-out)" />}
- hint="Median time from a new conversation to the first reply"
+ hint={t("analyticsMedianFirstReply")}
/>
}
- hint="Median time from a new conversation to closing it"
+ hint={t("analyticsMedianToClose")}
/>
Right now · excl. snoozed}
- hint="Conversations currently open (not snoozed)"
+ label={t("analyticsOpenNow")}
+ value={formatCount(kpis.open, "—", locale)}
+ sub={{t("analyticsRightNowExclSnoozed")} }
+ hint={t("analyticsOpenNotSnoozed")}
/>
}
- hint="Conversations closed in this period"
+ hint={t("analyticsClosedThisPeriod")}
/>
)
diff --git a/src/components/analytics/stats-tables.tsx b/src/components/analytics/stats-tables.tsx
index ad76b88..7e9271b 100644
--- a/src/components/analytics/stats-tables.tsx
+++ b/src/components/analytics/stats-tables.tsx
@@ -7,6 +7,7 @@ import { UserAvatar } from "@/components/app/user-avatar"
import { formatCount, formatDuration } from "@/components/analytics/format"
import type { InboxStat, TeammateStat } from "@/server/workspace/queries/analytics"
import { cn } from "@/lib/utils"
+import { useI18n } from "@/lib/i18n"
type Column = {
key: string
@@ -117,7 +118,8 @@ function SortableTable({
}
function Zeroable({ n }: { n: number }) {
- return {formatCount(n)}
+ const { locale } = useI18n()
+ return {formatCount(n, "—", locale)}
}
function Duration({ s }: { s: number | null }) {
@@ -137,11 +139,12 @@ function TableCard({ title, description, children }: { title: string; descriptio
}
export function TeammatesTable({ rows }: { rows: TeammateStat[] }) {
+ const { t } = useI18n()
const columns = useMemo[]>(
() => [
{
key: "name",
- label: "Teammate",
+ label: t("analyticsTableTeammate"),
sortValue: (r) => (r.name || r.email).toLowerCase(),
render: (r) => (
@@ -154,39 +157,40 @@ export function TeammatesTable({ rows }: { rows: TeammateStat[] }) {
),
className: "min-w-[200px]",
},
- { key: "assigned", label: "Assigned", align: "right", sortValue: (r) => r.assigned, render: (r) => },
- { key: "replies", label: "Replies", align: "right", sortValue: (r) => r.replies, render: (r) => },
- { key: "closed", label: "Closed", align: "right", sortValue: (r) => r.closed, render: (r) => },
+ { key: "assigned", label: t("analyticsAssigned"), align: "right", sortValue: (r) => r.assigned, render: (r) => },
+ { key: "replies", label: t("analyticsRepliesCount"), align: "right", sortValue: (r) => r.replies, render: (r) => },
+ { key: "closed", label: t("analyticsClosed"), align: "right", sortValue: (r) => r.closed, render: (r) => },
{
key: "frt",
- label: "First response",
+ label: t("analyticsFirstResponse"),
align: "right",
sortValue: (r) => r.medianFirstResponse,
render: (r) => ,
},
],
- []
+ [t]
)
return (
-
+
r.userId}
initialSort={{ key: "replies", dir: "desc" }}
- caption="Teammate performance"
- empty="No teammates match these filters."
+ caption={t("analyticsTeammatePerformance")}
+ empty={t("analyticsNoTeammateFilters")}
/>
)
}
export function InboxesTable({ rows }: { rows: InboxStat[] }) {
+ const { t } = useI18n()
const columns = useMemo[]>(
() => [
{
key: "name",
- label: "Inbox",
+ label: t("analyticsInbox"),
sortValue: (r) => r.name.toLowerCase(),
render: (r) => (
@@ -203,29 +207,29 @@ export function InboxesTable({ rows }: { rows: InboxStat[] }) {
),
className: "min-w-[200px]",
},
- { key: "received", label: "Received", align: "right", sortValue: (r) => r.received, render: (r) => },
- { key: "replies", label: "Replies", align: "right", sortValue: (r) => r.replies, render: (r) => },
+ { key: "received", label: t("analyticsReceived"), align: "right", sortValue: (r) => r.received, render: (r) => },
+ { key: "replies", label: t("analyticsRepliesCount"), align: "right", sortValue: (r) => r.replies, render: (r) => },
{
key: "frt",
- label: "First response",
+ label: t("analyticsFirstResponse"),
align: "right",
sortValue: (r) => r.medianFirstResponse,
render: (r) => ,
},
- { key: "open", label: "Open now", align: "right", sortValue: (r) => r.open, render: (r) => },
- { key: "closed", label: "Closed", align: "right", sortValue: (r) => r.closed, render: (r) => },
+ { key: "open", label: t("analyticsOpenNow"), align: "right", sortValue: (r) => r.open, render: (r) => },
+ { key: "closed", label: t("analyticsClosed"), align: "right", sortValue: (r) => r.closed, render: (r) => },
],
- []
+ [t]
)
return (
-
+
r.accountId}
initialSort={{ key: "received", dir: "desc" }}
- caption="Inbox performance"
- empty="No shared inboxes match these filters."
+ caption={t("analyticsInboxPerformance")}
+ empty={t("analyticsNoInboxesFilters")}
/>
)
diff --git a/src/components/analytics/top-labels.tsx b/src/components/analytics/top-labels.tsx
index 11133a3..846dfde 100644
--- a/src/components/analytics/top-labels.tsx
+++ b/src/components/analytics/top-labels.tsx
@@ -4,30 +4,32 @@ import { ChartCard, DataTable } from "@/components/analytics/chart-card"
import { ChartEmpty } from "@/components/analytics/trend-charts"
import { formatCount } from "@/components/analytics/format"
import type { LabelStat } from "@/server/workspace/queries/analytics"
+import { useI18n } from "@/lib/i18n"
/**
* Horizontal bars, one series (slot color); the label's own color is shown
* as a dot beside its name for identity.
*/
export function TopLabels({ labels }: { labels: LabelStat[] }) {
+ const { t, locale } = useI18n()
const max = Math.max(1, ...labels.map((l) => l.count))
return (
({ name: l.name, count: formatCount(l.count) }))}
/>
}
>
{labels.length === 0 ? (
- No labeled conversations in this period.
+ {t("noLabeledConversations")}
) : (
{labels.map((l) => {
@@ -44,12 +46,12 @@ export function TopLabels({ labels }: { labels: LabelStat[] }) {
{l.name}
-
+
- {formatCount(l.count)}
+ {formatCount(l.count, "—", locale)}
)
diff --git a/src/components/analytics/trend-charts.tsx b/src/components/analytics/trend-charts.tsx
index a54de49..3ba3ea4 100644
--- a/src/components/analytics/trend-charts.tsx
+++ b/src/components/analytics/trend-charts.tsx
@@ -5,8 +5,8 @@ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { ChartCard, DataTable, Legend } from "@/components/analytics/chart-card"
import { formatBucket, formatCount, formatDuration } from "@/components/analytics/format"
-import { responseConfig, volumeConfig } from "@/components/analytics/palette"
import type { SeriesPoint } from "@/server/workspace/queries/analytics"
+import { useI18n } from "@/lib/i18n"
type Granularity = "day" | "week"
@@ -17,26 +17,27 @@ const axisProps = {
} as const
export function VolumeChart({ series, granularity }: { series: SeriesPoint[]; granularity: Granularity }) {
+ const { t, locale } = useI18n()
const total = series.reduce((a, p) => a + p.received + p.replied, 0)
return (
}
table={
({
date: formatBucket(p.date, granularity, true),
@@ -47,18 +48,18 @@ export function VolumeChart({ series, granularity }: { series: SeriesPoint[]; gr
}
>
{total === 0 ? (
- No conversations or replies in this period.
+ {t("noConversationReplies")}
) : (
-
+
- formatBucket(v, granularity)} />
- formatCount(v)} />
+ formatBucket(v, granularity, false, locale)} />
+ formatCount(v, "—", locale)} />
formatBucket(String(value), granularity, true)}
+ labelFormatter={(value) => formatBucket(String(value), granularity, true, locale)}
/>
}
/>
@@ -99,6 +100,7 @@ function durationUnit(maxSeconds: number): { div: number; suffix: string } {
}
export function ResponseTimeChart({ series, granularity }: { series: SeriesPoint[]; granularity: Granularity }) {
+ const { t, locale } = useI18n()
const values = series.map((p) => p.medianFirstResponse).filter((v): v is number => v !== null)
const unit = durationUnit(values.length ? Math.max(...values) : 0)
const data = useMemo(
@@ -112,14 +114,14 @@ export function ResponseTimeChart({ series, granularity }: { series: SeriesPoint
)
return (
({
date: formatBucket(p.date, granularity, true),
@@ -129,25 +131,25 @@ export function ResponseTimeChart({ series, granularity }: { series: SeriesPoint
}
>
{values.length === 0 ? (
- No answered conversations in this period.
+ {t("noAnsweredConversations")}
) : (
-
+