263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { loadWebConfig } from "@toolctrl/config";
|
|
import type { AnalyticsSettings } from "@toolctrl/db/analytics.server";
|
|
import {
|
|
deleteAllAnalyticsData,
|
|
getOrCreateAnalyticsSettings,
|
|
removeAnalyticsIgnoredPage,
|
|
updateAnalyticsSettingsPreferences,
|
|
} from "@toolctrl/db/analytics.server";
|
|
import { IMPERSONATED_DESTRUCTIVE_ACTION_CONFIRMATION } from "@toolctrl/db/impersonation-audit.constants";
|
|
import { createImpersonationAudit } from "@toolctrl/db/impersonation-audit.server";
|
|
import { createAuditEvent } from "@toolctrl/db/audit-events.server";
|
|
import { extractWebActorIp, extractWebActorUserAgent } from "~/utils/audit/withAuditContext";
|
|
import { ActionFunction, LoaderFunction } from "react-router";
|
|
import { Form, useActionData, useLoaderData, useNavigation, useSubmit } from "react-router";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
|
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
|
import InputCheckboxWithDescription from "~/components/ui/input/InputCheckboxWithDescription";
|
|
import InputText from "~/components/ui/input/InputText";
|
|
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
|
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
|
import SettingSection from "~/components/ui/sections/SettingSection";
|
|
import { getTranslations } from "~/locale/i18next.server";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
|
|
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
|
import Tabs from "~/components/ui/tabs/Tabs";
|
|
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import { absoluteAdminPath } from "~/utils/app/CrossHostUrls";
|
|
|
|
type LoaderData = {
|
|
settings: AnalyticsSettings;
|
|
isLocalDev: boolean;
|
|
};
|
|
export const loader: LoaderFunction = async ({ request }) => {
|
|
await verifyUserHasPermission(request, "admin.analytics.delete");
|
|
const settings = await getOrCreateAnalyticsSettings();
|
|
const data: LoaderData = {
|
|
settings,
|
|
isLocalDev: loadWebConfig().config.nodeEnv === "development",
|
|
};
|
|
return Response.json(data);
|
|
};
|
|
|
|
type ActionData = {
|
|
deleteError?: string;
|
|
deleteSuccess?: boolean;
|
|
};
|
|
export const action: ActionFunction = async ({ request }) => {
|
|
await verifyUserHasPermission(request, "admin.analytics.delete");
|
|
const { t } = await getTranslations(request);
|
|
await validateCSRFToken(request);
|
|
|
|
const form = await request.formData();
|
|
const action = form.get("action");
|
|
if (action === "delete-all") {
|
|
const userInfo = await getUserInfo(request);
|
|
const impersonationConfirmation = form.get("impersonation-confirm")?.toString() ?? "";
|
|
if (userInfo.impersonatingFromUserId && impersonationConfirmation !== IMPERSONATED_DESTRUCTIVE_ACTION_CONFIRMATION) {
|
|
await createImpersonationAudit({
|
|
actorUserId: userInfo.impersonatingFromUserId,
|
|
targetUserId: userInfo.userId,
|
|
tenantId: null,
|
|
action: "destructive_action_blocked",
|
|
reason: "analytics.delete-all",
|
|
requestPath: new URL(request.url).pathname,
|
|
});
|
|
// Phase 10 Task #67: consolidated AuditEvent for compliance export.
|
|
await createAuditEvent({
|
|
tenantId: null,
|
|
actorId: userInfo.impersonatingFromUserId,
|
|
actorType: "admin",
|
|
actorIp: extractWebActorIp(request),
|
|
actorUserAgent: extractWebActorUserAgent(request),
|
|
action: "admin.impersonation.destructive_blocked",
|
|
targetType: "user",
|
|
targetId: userInfo.userId,
|
|
sourceModule: "admin",
|
|
outcome: "blocked",
|
|
dataSensitivity: "confidential",
|
|
regulatoryScope: ["soc2"],
|
|
metadata: { requestPath: new URL(request.url).pathname, reason: "analytics.delete-all" },
|
|
});
|
|
return Response.json({ deleteError: t("admin.analytics.settings.errors.confirmImpersonatedDelete") }, { status: 400 });
|
|
}
|
|
|
|
await deleteAllAnalyticsData();
|
|
return Response.json({
|
|
deleteSuccess: true,
|
|
});
|
|
}
|
|
if (action === "set-settings") {
|
|
const isPublicStr = form.get("public");
|
|
const isPublic = isPublicStr === "true" || isPublicStr === "on";
|
|
const ignorePage = form.get("ignore-page")?.toString() ?? "";
|
|
await updateAnalyticsSettingsPreferences({ public: isPublic, ignorePage });
|
|
return Response.json({
|
|
setSettingsSuccess: true,
|
|
});
|
|
}
|
|
if (action === "remove-ignored-page") {
|
|
const ignoredPage = form.get("ignored-page")?.toString() ?? "";
|
|
await removeAnalyticsIgnoredPage(ignoredPage);
|
|
return Response.json({
|
|
setSettingsSuccess: true,
|
|
});
|
|
} else {
|
|
return Response.json({
|
|
error: t("shared.invalidForm"),
|
|
});
|
|
}
|
|
};
|
|
|
|
export default function AdminAnalyticsOverviewRoute() {
|
|
const { t } = useTranslation();
|
|
const data = useLoaderData<LoaderData>();
|
|
const actionData = useActionData<ActionData>();
|
|
const submit = useSubmit();
|
|
const navigation = useNavigation();
|
|
const isUpdatingSettings = navigation.state === "submitting" && navigation.formData?.get("action") === "set-settings";
|
|
const rootData = useRootData();
|
|
const analyticsOverviewUrl = absoluteAdminPath(rootData.routing, "analytics/overview");
|
|
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
const confirmDelete = useRef<RefConfirmModal>(null);
|
|
const textToType = t("analytics.danger.confirmText");
|
|
|
|
const [typeToConfirm, setTypeToConfirm] = useState<string>(data.isLocalDev ? textToType : "");
|
|
const [isPublic, setIsPublic] = useState(data?.settings.public);
|
|
|
|
useEffect(() => {
|
|
if (!isUpdatingSettings) {
|
|
formRef.current?.reset();
|
|
}
|
|
}, [isUpdatingSettings]);
|
|
|
|
function removeIgnoredPage(item: string) {
|
|
const form = new FormData();
|
|
form.set("action", "remove-ignored-page");
|
|
form.set("ignored-page", item);
|
|
form.set("csrf", rootData.csrf ?? "");
|
|
submit(form, { method: "post" });
|
|
}
|
|
|
|
function onDelete() {
|
|
confirmDelete.current?.show(t("shared.delete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
|
|
}
|
|
|
|
function onDeleteConfirm() {
|
|
const form = new FormData();
|
|
form.set("action", "delete-all");
|
|
form.set("impersonation-confirm", typeToConfirm);
|
|
form.set("csrf", rootData.csrf ?? "");
|
|
submit(form, {
|
|
method: "post",
|
|
});
|
|
}
|
|
|
|
const impersonationTextToType = IMPERSONATED_DESTRUCTIVE_ACTION_CONFIRMATION;
|
|
const requiredDeleteConfirmation = rootData.impersonatingSession ? impersonationTextToType : textToType;
|
|
return (
|
|
<>
|
|
<EditPageLayout>
|
|
<div className="flex justify-between gap-2">
|
|
<Tabs
|
|
tabs={[
|
|
{ name: t("analytics.overview"), routePath: adminPath("analytics/overview", { mode: "host-aware" }) },
|
|
{ name: t("analytics.uniqueVisitors"), routePath: adminPath("analytics/visitors", { mode: "host-aware" }) },
|
|
{ name: t("analytics.pageViews"), routePath: adminPath("analytics/page-views", { mode: "host-aware" }) },
|
|
{ name: t("analytics.events"), routePath: adminPath("analytics/events", { mode: "host-aware" }) },
|
|
{ name: t("analytics.settings"), routePath: adminPath("analytics/settings", { mode: "host-aware" }) },
|
|
]}
|
|
className="grow"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-6 p-4 sm:px-6 lg:col-span-9 lg:px-0">
|
|
<SettingSection title={t("analytics.preferences.title")} description={t("analytics.preferences.description")}>
|
|
<Form ref={formRef} method="post">
|
|
<input hidden readOnly name="action" value="set-settings" />
|
|
<input hidden readOnly name="csrf" value={rootData.csrf ?? ""} />
|
|
<div className="space-y-2">
|
|
<InputCheckboxWithDescription
|
|
name="public"
|
|
title={t("shared.public")}
|
|
description={
|
|
<div className="text-muted-foreground">
|
|
{t("analytics.preferences.publicDescription")}{" "}
|
|
<a className="underline" target="_blank" rel="noreferrer" href={analyticsOverviewUrl}>
|
|
{analyticsOverviewUrl}
|
|
</a>
|
|
</div>
|
|
}
|
|
value={isPublic}
|
|
setValue={setIsPublic}
|
|
/>
|
|
<div className="space-y-2">
|
|
<div className="text-muted-foreground text-sm font-medium">{t("analytics.preferences.ignoredPages")}</div>
|
|
<div className="border-border bg-secondary space-y-1 rounded-md border p-3">
|
|
{data.settings.ignorePages.length === 0 && <div className="text-muted-foreground text-xs">{t("analytics.preferences.noIgnoredPages")}</div>}
|
|
{data.settings.ignorePages
|
|
.split(",")
|
|
.filter((f) => f)
|
|
.map((item) => {
|
|
return (
|
|
<div key={item} className="text-muted-foreground border-border group flex space-x-2 border-b text-xs">
|
|
<div>{item}</div>
|
|
<button type="button" className="hidden text-xs text-red-500 underline group-hover:block" onClick={() => removeIgnoredPage(item)}>
|
|
{t("shared.remove")}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<InputText name="ignore-page" title={t("analytics.preferences.addIgnoredPage")} defaultValue="" />
|
|
</div>
|
|
<div className="border-border mt-3 flex items-center justify-end space-x-2 border-t pt-3">
|
|
<LoadingButton type="submit" disabled={isUpdatingSettings}>
|
|
{t("shared.save")}
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
</SettingSection>
|
|
|
|
<SettingSection title={t("analytics.danger.title")} description={t("analytics.danger.description")}>
|
|
<div>
|
|
<div className="text-muted-foreground mt-2 max-w-xl text-sm leading-5">
|
|
<p>{t("analytics.danger.reset.description")}</p>
|
|
</div>
|
|
<div className="mt-5 space-y-2">
|
|
<div>
|
|
<InputText title={t("analytics.danger.confirm", { value: requiredDeleteConfirmation })} value={typeToConfirm} setValue={setTypeToConfirm} />
|
|
</div>
|
|
<div>
|
|
<ButtonPrimary disabled={typeToConfirm !== requiredDeleteConfirmation} destructive={true} onClick={onDelete}>
|
|
{t("analytics.danger.reset.title")}
|
|
</ButtonPrimary>
|
|
|
|
{actionData?.deleteSuccess ? (
|
|
<p className="py-2 text-xs text-green-500" role="alert">
|
|
{t("analytics.deleted")}
|
|
</p>
|
|
) : null}
|
|
|
|
{actionData?.deleteError ? (
|
|
<p className="py-2 text-xs text-rose-500" role="alert">
|
|
{actionData.deleteError}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingSection>
|
|
</div>
|
|
|
|
<ConfirmModal ref={confirmDelete} onYes={onDeleteConfirm} destructive />
|
|
</EditPageLayout>
|
|
</>
|
|
);
|
|
}
|