This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
// "Your analytics data" (ADR-049): what the platform's own stores keep about the person's visits --
|
||||
// session replay, errors, access logs, traces, logs and analytics -- erased or exported by one
|
||||
// platform job. Both buttons call this app's server (account.analytics.*), which calls the platform
|
||||
// with the deployment token; the browser holds only the ticket of the job it started, in memory.
|
||||
// The erasure asks for a confirmation first, because it cannot be undone. While a job is open the
|
||||
// panel polls its status; a completed export offers the download.
|
||||
|
||||
import { DataTable, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useCallback, useEffect, useId, useState } from "react"
|
||||
import type { AnalyticsView } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountAnalyticsLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
readonly erase: string
|
||||
readonly exportSubmit: string
|
||||
readonly busy: string
|
||||
readonly confirmErase: string
|
||||
readonly confirm: string
|
||||
readonly cancel: string
|
||||
readonly download: string
|
||||
readonly filename: string
|
||||
/** One per job status: pending, running, completed, failed, expired. */
|
||||
readonly status: Readonly<Record<AnalyticsView["status"], string>>
|
||||
readonly store: string
|
||||
readonly storeStatus: string
|
||||
readonly deleted: string
|
||||
readonly anonymized: string
|
||||
readonly exported: string
|
||||
/** A readable name per store key; a key without one is shown as it is. */
|
||||
readonly storeNames: Readonly<Record<string, string>>
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** How often an open job is asked about. */
|
||||
export const ANALYTICS_POLL_MS = 5_000
|
||||
|
||||
type Busy = "erase" | "export" | "download" | null
|
||||
|
||||
function save(document: Record<string, unknown>, filename: string, exportedAt: string): void {
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }))
|
||||
const link = window.document.createElement("a")
|
||||
link.href = href
|
||||
link.download = `${filename}-${exportedAt.slice(0, 10)}.json`
|
||||
window.document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
}
|
||||
|
||||
const isOpen = (job: AnalyticsView | null) => job !== null && (job.status === "pending" || job.status === "running")
|
||||
|
||||
export function AccountAnalyticsPanel({
|
||||
labels,
|
||||
pollMs = ANALYTICS_POLL_MS,
|
||||
}: {
|
||||
labels: AccountAnalyticsLabels
|
||||
pollMs?: number
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const [jobs, setJobs] = useState<{ erase: AnalyticsView | null; export: AnalyticsView | null }>({
|
||||
erase: null,
|
||||
export: null,
|
||||
})
|
||||
const [busy, setBusy] = useState<Busy>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
|
||||
const remember = useCallback((job: AnalyticsView) => setJobs((current) => ({ ...current, [job.kind]: job })), [])
|
||||
|
||||
const start = async (kind: AnalyticsView["kind"]): Promise<void> => {
|
||||
setBusy(kind)
|
||||
setFailure(null)
|
||||
setConfirming(false)
|
||||
try {
|
||||
remember((await call("account.analytics.request", { kind })) as AnalyticsView)
|
||||
} catch (error) {
|
||||
setFailure(describe(error))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async (job: AnalyticsView): Promise<void> => {
|
||||
setBusy("download")
|
||||
setFailure(null)
|
||||
try {
|
||||
const answer = (await call("account.analytics.export", { ticket: job.ticket })) as {
|
||||
exportedAt: string
|
||||
document: Record<string, unknown>
|
||||
}
|
||||
save(answer.document, labels.filename, answer.exportedAt)
|
||||
} catch (error) {
|
||||
setFailure(describe(error))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow every open job until it settles.
|
||||
useEffect(() => {
|
||||
const open = [jobs.erase, jobs.export].filter(isOpen) as AnalyticsView[]
|
||||
if (open.length === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
for (const job of open) {
|
||||
call("account.analytics.status", { ticket: job.ticket })
|
||||
.then((next) => remember(next as AnalyticsView))
|
||||
.catch((error: unknown) => setFailure(describe(error)))
|
||||
}
|
||||
}, pollMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [jobs, call, describe, remember, pollMs])
|
||||
|
||||
const rows = (job: AnalyticsView) =>
|
||||
Object.entries(job.stores).map(([store, outcome]) => ({
|
||||
id: store,
|
||||
store: labels.storeNames[store] ?? store,
|
||||
status: outcome.status,
|
||||
deleted: outcome.deleted,
|
||||
anonymized: outcome.anonymized,
|
||||
exported: outcome.exported,
|
||||
}))
|
||||
|
||||
const jobView = (job: AnalyticsView | null) =>
|
||||
job === null ? null : (
|
||||
<div className="app-stack" data-kind={job.kind}>
|
||||
<p role="status">{labels.status[job.status]}</p>
|
||||
{Object.keys(job.stores).length > 0 ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ id: "store", header: labels.store },
|
||||
{ id: "status", header: labels.storeStatus },
|
||||
...(job.kind === "erase"
|
||||
? [
|
||||
{ id: "deleted" as const, header: labels.deleted },
|
||||
{ id: "anonymized" as const, header: labels.anonymized },
|
||||
]
|
||||
: [{ id: "exported" as const, header: labels.exported }]),
|
||||
]}
|
||||
rows={rows(job)}
|
||||
/>
|
||||
) : null}
|
||||
{job.kind === "export" && job.status === "completed" ? (
|
||||
<button type="button" onClick={() => download(job)} disabled={busy !== null} aria-busy={busy === "download"}>
|
||||
{busy === "download" ? labels.busy : labels.download}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
<div className="app-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => start("export")}
|
||||
disabled={busy !== null || isOpen(jobs.export)}
|
||||
aria-busy={busy === "export"}
|
||||
>
|
||||
{busy === "export" ? labels.busy : labels.exportSubmit}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="app-danger"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={busy !== null || confirming || isOpen(jobs.erase)}
|
||||
>
|
||||
{busy === "erase" ? labels.busy : labels.erase}
|
||||
</button>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div role="alertdialog" aria-labelledby={`${headingId}-confirm`} className="app-confirm">
|
||||
<p id={`${headingId}-confirm`}>{labels.confirmErase}</p>
|
||||
<button type="button" className="app-danger" onClick={() => start("erase")}>
|
||||
{labels.confirm}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)}>
|
||||
{labels.cancel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{jobView(jobs.export)}
|
||||
{jobView(jobs.erase)}
|
||||
{failure === null ? null : (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", failure)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
// "Delete my account": the person types the account's e-mail address and confirms, from a session
|
||||
// whose sign-in is recent (the server checks both; this form only says so first). The answer is the
|
||||
// deletion's state: completed, or erasing while a module's service is unavailable -- in both cases the
|
||||
// person is signed out everywhere, and the app's browser modules clear what they kept (`onDeleted`).
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { DeletionView } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountDeletionLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
/** The services whose data is erased, already named; empty when there are none besides the app. */
|
||||
readonly services: string
|
||||
/** Set when the person is the last owner of an organization others belong to: the form is closed. */
|
||||
readonly lastOwner?: string
|
||||
/** Set when an organization would be left without members. */
|
||||
readonly onlyMember?: string
|
||||
readonly reauth: string
|
||||
readonly reauthLink: string
|
||||
readonly notInteractive: string
|
||||
readonly confirmLabel: string
|
||||
readonly confirmHint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly completed: string
|
||||
/** With `{modules}` still in it. */
|
||||
readonly erasing: string
|
||||
readonly home: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
readonly sectionNames: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface AccountDeletionProps {
|
||||
readonly labels: AccountDeletionLabels
|
||||
/** Whether the session may delete now: a person, signed in recently, owning no organization alone. */
|
||||
readonly interactive: boolean
|
||||
readonly recentSignIn: boolean
|
||||
/** Where a person signs in again and comes back to this page. */
|
||||
readonly reauthHref: string
|
||||
/** A deletion already under way, when the page was rendered. */
|
||||
readonly deletion: DeletionView | null
|
||||
/** Runs once the deletion answered, before the result is shown: the app ends its browser modules' context. */
|
||||
readonly onDeleted?: (deletion: DeletionView) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly kind: "idle" | "busy" }
|
||||
| { readonly kind: "deleted"; readonly deletion: DeletionView }
|
||||
| { readonly kind: "failed"; readonly detail: string; readonly code: string }
|
||||
|
||||
const codeOf = (error: unknown): string => {
|
||||
const problem = (error as { problem?: { code?: unknown } } | null)?.problem
|
||||
return typeof problem?.code === "string" ? problem.code : "unknown"
|
||||
}
|
||||
|
||||
export function AccountDeletionPanel(props: AccountDeletionProps): ReactElement {
|
||||
const { labels } = props
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>(
|
||||
props.deletion ? { kind: "deleted", deletion: props.deletion } : { kind: "idle" },
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const confirm = String(new FormData(event.currentTarget).get("confirm") ?? "").trim()
|
||||
if (confirm.length === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const deletion = (await call("account.deletion.request", { confirm })) as DeletionView
|
||||
// The session is revoked now: what the browser modules kept for it goes too (contract §12).
|
||||
await Promise.resolve(props.onDeleted?.(deletion)).catch(() => undefined)
|
||||
setState({ kind: "deleted", deletion })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error), code: codeOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const reauth = (
|
||||
<p role="status">
|
||||
{labels.reauth} <a href={props.reauthHref}>{labels.reauthLink}</a>
|
||||
</p>
|
||||
)
|
||||
|
||||
if (state.kind === "deleted") {
|
||||
const pending = state.deletion.pending.map((key) => labels.sectionNames[key] ?? key).join(", ")
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p role="status">
|
||||
{state.deletion.state === "completed" ? labels.completed : labels.erasing.replace("{modules}", pending)}
|
||||
</p>
|
||||
<a href="/">{labels.home}</a>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const blocked = !props.interactive || labels.lastOwner !== undefined
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
{labels.services ? <p>{labels.services}</p> : null}
|
||||
{labels.onlyMember ? <p>{labels.onlyMember}</p> : null}
|
||||
{labels.lastOwner ? <p role="status">{labels.lastOwner}</p> : null}
|
||||
{!props.interactive ? <p role="status">{labels.notInteractive}</p> : null}
|
||||
{props.interactive && !props.recentSignIn ? reauth : null}
|
||||
{blocked ? null : (
|
||||
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
|
||||
<div className="kit-field">
|
||||
<label htmlFor={inputId}>{labels.confirmLabel}</label>
|
||||
<input
|
||||
id={inputId}
|
||||
name="confirm"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={hintId}
|
||||
/>
|
||||
<p id={hintId}>{labels.confirmHint}</p>
|
||||
</div>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{state.kind === "failed" && state.code === "reauthentication_required" ? reauth : null}
|
||||
{state.kind === "failed" && state.code !== "reauthentication_required" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
// "Export my data": one call, and the answer saved as a JSON file in the browser. A section that
|
||||
// could not be exported is named on the page as well as in the file, so a person never takes a
|
||||
// partial export for a whole one.
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useId, useState } from "react"
|
||||
import type { AccountExport } from "../contract/procedures.js"
|
||||
|
||||
export interface AccountExportLabels {
|
||||
readonly heading: string
|
||||
readonly body: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
/** With `{sections}` still in it. */
|
||||
readonly partial: string
|
||||
readonly filename: string
|
||||
/** The app's `app.failed` message, with `{detail}` still in it. */
|
||||
readonly failed: string
|
||||
/** A readable name per section key; a key without one is shown as it is. */
|
||||
readonly sectionNames: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly kind: "idle" | "busy" | "done" }
|
||||
| { readonly kind: "partial"; readonly sections: string }
|
||||
| { readonly kind: "failed"; readonly detail: string }
|
||||
|
||||
function save(exported: AccountExport, filename: string): void {
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: "application/json" }))
|
||||
const link = document.createElement("a")
|
||||
link.href = href
|
||||
link.download = `${filename}-${exported.exportedAt.slice(0, 10)}.json`
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
}
|
||||
|
||||
export function AccountExportPanel({ labels }: { labels: AccountExportLabels }): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const headingId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const exported = (await call("account.data.export", {})) as AccountExport
|
||||
save(exported, labels.filename)
|
||||
const missing = Object.entries(exported.sections)
|
||||
.filter(([, section]) => section.status !== "ok")
|
||||
.map(([key]) => labels.sectionNames[key] ?? key)
|
||||
setState(exported.complete ? { kind: "done" } : { kind: "partial", sections: missing.join(", ") })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<h2 id={headingId}>{labels.heading}</h2>
|
||||
<p>{labels.body}</p>
|
||||
<button type="button" onClick={run} disabled={state.kind === "busy"} aria-busy={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
|
||||
{state.kind === "partial" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.partial.replace("{sections}", state.sections)}
|
||||
</p>
|
||||
) : null}
|
||||
{state.kind === "failed" ? (
|
||||
<p role="alert" className="app-error">
|
||||
{labels.failed.replace("{detail}", state.detail)}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// The account module's components: the export and the deletion panels, and the label builders the
|
||||
// app's account page (`apps/web/app/settings/account`) builds them with.
|
||||
|
||||
export {
|
||||
type AccountAnalyticsLabels,
|
||||
AccountAnalyticsPanel,
|
||||
ANALYTICS_POLL_MS,
|
||||
} from "./account-analytics.js"
|
||||
export { type AccountDeletionLabels, AccountDeletionPanel, type AccountDeletionProps } from "./account-deletion.js"
|
||||
export { type AccountExportLabels, AccountExportPanel } from "./account-export.js"
|
||||
export { analyticsLabels, deletionLabels, exportLabels, pageLabels, sectionNames, type Translate } from "./labels.js"
|
||||
@@ -0,0 +1,111 @@
|
||||
// The labels each component takes, built from a message lookup. Every key the account page uses is
|
||||
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks -- the page itself holds no string.
|
||||
//
|
||||
// `t` takes a key relative to the module (`export.heading`); the page binds it to
|
||||
// `i18n.message("account." + key)`. The app's `app.failed` is passed in already looked up.
|
||||
|
||||
import { RECENT_SIGN_IN_SECONDS } from "../contract/constants.js"
|
||||
import type { DeletionStatus } from "../contract/procedures.js"
|
||||
import type { AccountAnalyticsLabels } from "./account-analytics.js"
|
||||
import type { AccountDeletionLabels } from "./account-deletion.js"
|
||||
import type { AccountExportLabels } from "./account-export.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The sections this module has a name for; another module's key is shown as it is. */
|
||||
const NAMED_SECTIONS = ["account", "guides", "support"] as const
|
||||
|
||||
export function sectionNames(t: Translate): Record<string, string> {
|
||||
return Object.fromEntries(NAMED_SECTIONS.map((key) => [key, t(`sections.${key}`)]))
|
||||
}
|
||||
|
||||
const named = (keys: readonly string[], names: Readonly<Record<string, string>>): string =>
|
||||
keys.map((key) => names[key] ?? key).join(", ")
|
||||
|
||||
export function pageLabels(t: Translate) {
|
||||
return {
|
||||
title: t("title"),
|
||||
intro: t("intro"),
|
||||
link: t("link"),
|
||||
profileHeading: t("profile.heading"),
|
||||
name: t("profile.name"),
|
||||
email: t("profile.email"),
|
||||
}
|
||||
}
|
||||
|
||||
export function exportLabels(t: Translate, failed: string): AccountExportLabels {
|
||||
return {
|
||||
heading: t("export.heading"),
|
||||
body: t("export.body"),
|
||||
submit: t("export.submit"),
|
||||
busy: t("export.busy"),
|
||||
done: t("export.done"),
|
||||
partial: t("export.partial", { sections: "{sections}" }),
|
||||
filename: t("export.filename"),
|
||||
failed,
|
||||
sectionNames: sectionNames(t),
|
||||
}
|
||||
}
|
||||
|
||||
export function deletionLabels(
|
||||
t: Translate,
|
||||
failed: string,
|
||||
status: Pick<DeletionStatus, "modules" | "lastOwnerOf" | "onlyMemberOf">,
|
||||
email: string,
|
||||
): AccountDeletionLabels {
|
||||
const names = sectionNames(t)
|
||||
const organizations = (list: readonly { name: string }[]) => list.map((org) => org.name).join(", ")
|
||||
return {
|
||||
heading: t("deletion.heading"),
|
||||
body: t("deletion.body"),
|
||||
services: status.modules.length > 0 ? t("deletion.services", { modules: named(status.modules, names) }) : "",
|
||||
...(status.lastOwnerOf.length > 0
|
||||
? { lastOwner: t("deletion.lastOwner", { organizations: organizations(status.lastOwnerOf) }) }
|
||||
: {}),
|
||||
...(status.onlyMemberOf.length > 0
|
||||
? { onlyMember: t("deletion.onlyMember", { organizations: organizations(status.onlyMemberOf) }) }
|
||||
: {}),
|
||||
reauth: t("deletion.reauth", { minutes: Math.round(RECENT_SIGN_IN_SECONDS / 60) }),
|
||||
reauthLink: t("deletion.reauthLink"),
|
||||
notInteractive: t("deletion.notInteractive"),
|
||||
confirmLabel: t("deletion.confirmLabel"),
|
||||
confirmHint: t("deletion.confirmHint", { email }),
|
||||
submit: t("deletion.submit"),
|
||||
busy: t("deletion.busy"),
|
||||
completed: t("deletion.completed"),
|
||||
erasing: t("deletion.erasing", { modules: "{modules}" }),
|
||||
home: t("deletion.home"),
|
||||
failed,
|
||||
sectionNames: names,
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform stores an analytics request reports on (ADR-049 §3). */
|
||||
const ANALYTICS_STORES = ["session_replay", "errors", "proxy_logs", "traces", "logs", "analytics"] as const
|
||||
const ANALYTICS_STATUSES = ["pending", "running", "completed", "failed", "expired"] as const
|
||||
|
||||
export function analyticsLabels(t: Translate, failed: string): AccountAnalyticsLabels {
|
||||
return {
|
||||
heading: t("analytics.heading"),
|
||||
body: t("analytics.body"),
|
||||
erase: t("analytics.erase"),
|
||||
exportSubmit: t("analytics.exportSubmit"),
|
||||
busy: t("analytics.busy"),
|
||||
confirmErase: t("analytics.confirmErase"),
|
||||
confirm: t("analytics.confirm"),
|
||||
cancel: t("analytics.cancel"),
|
||||
download: t("analytics.download"),
|
||||
filename: t("analytics.filename"),
|
||||
status: Object.fromEntries(
|
||||
ANALYTICS_STATUSES.map((key) => [key, t(`analytics.status.${key}`)]),
|
||||
) as AccountAnalyticsLabels["status"],
|
||||
store: t("analytics.store"),
|
||||
storeStatus: t("analytics.storeStatus"),
|
||||
deleted: t("analytics.deleted"),
|
||||
anonymized: t("analytics.anonymized"),
|
||||
exported: t("analytics.exported"),
|
||||
storeNames: Object.fromEntries(ANALYTICS_STORES.map((key) => [key, t(`analytics.stores.${key}`)])),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user