Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,436 @@
import { useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Button,
Checkbox,
CloseButton,
Group,
Modal,
PasswordInput,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import {
ChangeUserPasswordRequest,
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ChangeUserPasswordModalProps {
opened: boolean;
onClose: () => void;
user: User | null;
onSuccess: () => void;
mailEnabled: boolean;
}
function generateSecurePassword() {
const charset =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
const length = 14;
let password = "";
const charsetLength = charset.length;
const uint8Array = new Uint8Array(length);
window.crypto.getRandomValues(uint8Array);
// To avoid modulo bias, discard values >= 256 - (256 % charsetLength)
for (let i = 0; password.length < length; ) {
const randomByte = uint8Array[i];
i++;
if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) {
// Discard and generate a new random value
if (i >= uint8Array.length) {
// Exhausted the array, fill a new one
window.crypto.getRandomValues(uint8Array);
i = 0;
}
continue;
}
const randomIndex = randomByte % charsetLength;
password += charset[randomIndex];
}
return password;
}
export default function ChangeUserPasswordModal({
opened,
onClose,
user,
onSuccess,
mailEnabled,
}: ChangeUserPasswordModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
const [processing, setProcessing] = useState(false);
const disabled = !user;
const handleGeneratePassword = () => {
const generated = generateSecurePassword();
setForm((prev) => ({
...prev,
newPassword: generated,
confirmPassword: generated,
generateRandom: true,
}));
};
const handleCopyPassword = async () => {
if (!form.newPassword) return;
try {
await navigator.clipboard.writeText(form.newPassword);
alert({
alertType: "success",
title: t(
"workspace.people.changePassword.copiedToClipboard",
"Password copied to clipboard",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.copyFailed",
"Failed to copy password",
),
});
}
};
const resetState = () => {
setForm({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
};
const handleClose = () => {
if (processing) return;
resetState();
onClose();
};
const handleSubmit = async () => {
if (!user) return;
if (!form.generateRandom && !form.newPassword.trim()) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.passwordRequired",
"Please enter a new password",
),
});
return;
}
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
),
});
return;
}
const payload: ChangeUserPasswordRequest = {
username: user.username,
newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true)
generateRandom: false, // Not needed since we're generating on frontend
sendEmail: form.sendEmail,
includePassword: form.includePassword,
forcePasswordChange: form.forcePasswordChange,
};
try {
setProcessing(true);
await userManagementService.changeUserPassword(payload);
alert({
alertType: "success",
title: t(
"workspace.people.changePassword.success",
"Password updated successfully",
),
});
onSuccess();
handleClose();
} catch (error: unknown) {
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.changePassword.error",
"Failed to update password",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
useEffect(() => {
if (opened) {
setForm({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
}
}, [opened, user?.username]);
// Check if username is a valid email format
const isValidEmail = (email: string | undefined) => {
if (!email) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const canEmail = mailEnabled && isValidEmail(user?.username);
const passwordPreview = useMemo(
() => (form.newPassword && form.generateRandom ? form.newPassword : ""),
[form.generateRandom, form.newPassword],
);
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon
icon="lock"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.changePassword.title", "Change password")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"workspace.people.changePassword.subtitle",
"Update the password for",
)}{" "}
<strong>{user?.username}</strong>
</Text>
</Stack>
<Stack gap="sm">
<PasswordInput
label={t(
"workspace.people.changePassword.newPassword",
"New password",
)}
placeholder={t(
"workspace.people.changePassword.placeholder",
"Enter a new password",
)}
value={form.newPassword}
onChange={(event) =>
setForm({
...form,
newPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
data-autofocus
/>
<PasswordInput
label={t(
"workspace.people.changePassword.confirmPassword",
"Confirm password",
)}
placeholder={t(
"workspace.people.changePassword.confirmPlaceholder",
"Re-enter the new password",
)}
value={form.confirmPassword}
onChange={(event) =>
setForm({
...form,
confirmPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
error={
!form.generateRandom &&
form.confirmPassword &&
form.newPassword !== form.confirmPassword
? t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
)
: undefined
}
/>
<Group justify="space-between">
<Checkbox
label={t(
"workspace.people.changePassword.generateRandom",
"Generate secure password",
)}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({ ...prev, generateRandom: checked }));
if (event.currentTarget.checked) {
handleGeneratePassword();
}
}}
/>
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.generatedPreview",
"Generated password:",
)}{" "}
<strong>{passwordPreview}</strong>
</Text>
<Tooltip
label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={handleCopyPassword}
disabled={processing}
>
<LocalIcon
icon="content-copy"
width="0.9rem"
height="0.9rem"
/>
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
label={t(
"workspace.people.changePassword.sendEmail",
"Email the user about this change",
)}
checked={canEmail && form.sendEmail}
onChange={(event) =>
setForm({ ...form, sendEmail: event.currentTarget.checked })
}
disabled={!canEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.includePassword",
"Include the new password in the email",
)}
checked={canEmail && form.sendEmail && form.includePassword}
onChange={(event) =>
setForm({
...form,
includePassword: event.currentTarget.checked,
})
}
disabled={!canEmail || !form.sendEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.forcePasswordChange",
"Force user to change password on next login",
)}
checked={form.forcePasswordChange}
onChange={(event) =>
setForm({
...form,
forcePasswordChange: event.currentTarget.checked,
})
}
disabled={processing || disabled}
/>
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t(
"workspace.people.changePassword.emailUnavailable",
"This user's email is not a valid email address. Notifications are disabled.",
)
: t(
"workspace.people.changePassword.smtpDisabled",
"Email notifications require SMTP to be enabled in settings.",
)}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.notifyOnly",
"An email will be sent without the password, letting the user know an admin changed it.",
)}
</Text>
)}
</Stack>
<Button
onClick={handleSubmit}
loading={processing}
fullWidth
size="md"
disabled={disabled}
mt="md"
>
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
</div>
</Modal>
);
}
@@ -0,0 +1,46 @@
import "@app/components/shared/dividerWithText/DividerWithText.css";
interface TextDividerProps {
text?: string;
className?: string;
style?: React.CSSProperties;
variant?: "default" | "subcategory";
respondsToDarkMode?: boolean;
opacity?: number;
}
export default function DividerWithText({
text,
className = "",
style,
variant = "default",
respondsToDarkMode = true,
opacity,
}: TextDividerProps) {
const variantClass = variant === "subcategory" ? "subcategory" : "";
const themeClass = respondsToDarkMode ? "" : "force-light";
const styleWithOpacity =
opacity !== undefined
? { ...(style || {}), ["--text-divider-opacity" as string]: opacity }
: style;
if (text) {
return (
<div
className={`text-divider ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
);
}
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
);
}
@@ -0,0 +1,870 @@
import { useState, useEffect, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Modal,
Stack,
Text,
Button,
TextInput,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Box,
Group,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService } from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useNavigate } from "react-router-dom";
interface InviteMembersModalProps {
opened: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function InviteMembersModal({
opened,
onClose,
onSuccess,
}: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<"email" | "direct" | "link">(
"direct",
);
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(
null,
);
const actionTakenRef = useRef(false);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
authType: "WEB" as "WEB" | "OAUTH2" | "SAML2",
forceChange: false,
forceMFA: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Fetch teams and license info
useEffect(() => {
if (opened) {
const fetchData = async () => {
try {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setTeams(teamsData);
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} catch (error) {
console.error("Failed to fetch data:", error);
}
};
fetchData();
}
}, [opened]);
const roleOptions = [
{
value: "ROLE_USER",
label: t("workspace.people.roleDescriptions.user", "User"),
},
{
value: "ROLE_ADMIN",
label: t("workspace.people.roleDescriptions.admin", "Admin"),
},
];
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
const handleInviteUser = async () => {
if (!inviteForm.username) {
alert({
alertType: "error",
title: t("workspace.people.addMember.usernameRequired"),
});
return;
}
// Password is only required for WEB auth type
if (inviteForm.authType === "WEB" && !inviteForm.password) {
alert({
alertType: "error",
title: t(
"workspace.people.addMember.passwordRequired",
"Password is required",
),
});
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: inviteForm.authType,
forceChange: inviteForm.forceChange,
forceMFA: inviteForm.forceMFA,
});
alert({
alertType: "success",
title: t("workspace.people.addMember.success"),
});
onClose();
onSuccess?.();
// Reset form
setInviteForm({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: "WEB",
forceChange: false,
forceMFA: false,
});
} catch (error: unknown) {
console.error("Failed to invite user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.addMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({
alertType: "error",
title: t(
"workspace.people.emailInvite.emailsRequired",
"Email addresses are required",
),
});
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails, // comma-separated string as required by API
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
// Show success message
alert({
alertType: "success",
title: t("workspace.people.emailInvite.success", {
count: response.successCount,
defaultValue: `Successfully invited ${response.successCount} user(s)`,
}),
});
// Show warning if there were partial failures
if (response.failureCount > 0 && response.errors) {
alert({
alertType: "warning",
title: t(
"workspace.people.emailInvite.partialFailure",
"Some invites failed",
),
body: response.errors,
});
}
onClose();
onSuccess?.();
setEmailInviteForm({
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
} else {
alert({
alertType: "error",
title: t(
"workspace.people.emailInvite.allFailed",
"Failed to invite users",
),
body: response.errors || response.error,
});
}
} catch (error: unknown) {
console.error("Failed to invite users:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.emailInvite.error", "Failed to send invites");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email || undefined,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
frontendBaseUrl: config?.frontendUrl || window.location.origin,
});
actionTakenRef.current = true;
setGeneratedInviteLink(response.inviteUrl);
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.emailSent",
"Invite link generated and sent via email",
),
});
}
} catch (error: unknown) {
console.error("Failed to generate invite link:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.inviteLink.error",
"Failed to generate invite link",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleClose = () => {
if (actionTakenRef.current) {
onSuccess?.();
actionTakenRef.current = false;
}
setGeneratedInviteLink(null);
setInviteMode("direct");
setInviteForm({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: "WEB",
forceChange: false,
forceMFA: false,
});
setEmailInviteForm({
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
setInviteLinkForm({
email: "",
role: "ROLE_USER",
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
onClose();
};
const handleGoToPlan = () => {
handleClose();
navigate("/settings/adminPlan");
};
const handlePrimaryAction = () => {
if (inviteMode === "email") {
handleEmailInvite();
} else if (inviteMode === "link") {
handleGenerateInviteLink();
} else {
handleInviteUser();
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={handleClose}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.inviteMembers.label", "Invite Members")}
</Text>
{inviteMode === "email" && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t(
"workspace.people.inviteMembers.subtitle",
"Type or paste in emails below, separated by commas. Your workspace will be billed by members.",
)}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper
withBorder
p="sm"
bg={
licenseInfo.availableSlots === 0
? "var(--mantine-color-red-light)"
: "var(--mantine-color-blue-light)"
}
>
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<LocalIcon
icon={licenseInfo.availableSlots > 0 ? "info" : "warning"}
width="1rem"
height="1rem"
/>
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t("workspace.people.license.slotsAvailable", {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`,
})
: t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
</Text>
</Group>
{licenseInfo.availableSlots === 0 && (
<Button size="xs" variant="light" onClick={handleGoToPlan}>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
)}
</Group>
<Text size="xs" c="dimmed">
{t("workspace.people.license.currentUsage", {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`,
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t(
"workspace.people.inviteMode.emailDisabled",
"Email invites require SMTP configuration and mail.enableInvites=true in settings",
)}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as "email" | "direct" | "link");
setGeneratedInviteLink(null);
}}
data={[
{
label: t(
"workspace.people.inviteMode.username",
"Username",
),
value: "direct",
},
{
label: t("workspace.people.inviteMode.link", "Link"),
value: "link",
},
{
label: t("workspace.people.inviteMode.email", "Email"),
value: "email",
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === "link" && (
<>
<TextInput
label={t(
"workspace.people.inviteLink.email",
"Email (optional)",
)}
placeholder={t(
"workspace.people.inviteLink.emailPlaceholder",
"[email protected]",
)}
value={inviteLinkForm.email}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
email: e.currentTarget.value,
})
}
description={t(
"workspace.people.inviteLink.emailDescription",
"If provided, the link will be tied to this email address",
)}
/>
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) =>
setInviteLinkForm({
...inviteLinkForm,
role: value || "ROLE_USER",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) =>
setInviteLinkForm({
...inviteLinkForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<TextInput
label={t(
"workspace.people.inviteLink.expiryHours",
"Link expires in (hours)",
)}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
expiryHours: parseInt(e.currentTarget.value) || 72,
})
}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t(
"workspace.people.inviteLink.sendEmail",
"Send invite link via email",
)}
description={t(
"workspace.people.inviteLink.sendEmailDescription",
"Also send the link to the provided email address",
)}
checked={inviteLinkForm.sendEmail}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
sendEmail: e.currentTarget.checked,
})
}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>
{t(
"workspace.people.inviteLink.generated",
"Invite Link Generated",
)}
</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(
generatedInviteLink,
);
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement("textarea");
textArea.value = generatedInviteLink;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
}
}}
>
<LocalIcon
icon="content-copy"
width="1rem"
height="1rem"
/>
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === "email" && config?.enableEmailInvites && (
<>
<Textarea
label={t(
"workspace.people.emailInvite.emails",
"Email Addresses",
)}
placeholder={t(
"workspace.people.emailInvite.emailsPlaceholder",
"[email protected], [email protected]",
)}
value={emailInviteForm.emails}
onChange={(e) =>
setEmailInviteForm({
...emailInviteForm,
emails: e.currentTarget.value,
})
}
minRows={3}
required
/>
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) =>
setEmailInviteForm({
...emailInviteForm,
role: value || "ROLE_USER",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) =>
setEmailInviteForm({
...emailInviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === "direct" && (
<>
<TextInput
label={t("workspace.people.addMember.username")}
placeholder={t(
"workspace.people.addMember.usernamePlaceholder",
)}
value={inviteForm.username}
onChange={(e) =>
setInviteForm({
...inviteForm,
username: e.currentTarget.value,
})
}
required
/>
{/* Auth Type Selector - only show if SSO is enabled */}
{(config?.enableOAuth || config?.enableSaml) && (
<Select
label={t(
"workspace.people.addMember.authType",
"Authentication Type",
)}
data={[
{
value: "WEB",
label: t(
"workspace.people.authType.password",
"Password",
),
},
...(config?.enableOAuth
? [
{
value: "OAUTH2",
label: t(
"workspace.people.authType.oauth",
"OAuth2",
),
},
]
: []),
...(config?.enableSaml
? [
{
value: "SAML2",
label: t("workspace.people.authType.saml", "SAML2"),
},
]
: []),
]}
value={inviteForm.authType}
onChange={(value) =>
setInviteForm({
...inviteForm,
authType: (value as "WEB" | "OAUTH2" | "SAML2") || "WEB",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
description={
inviteForm.authType !== "WEB"
? t(
"workspace.people.authType.ssoDescription",
"User will authenticate via SSO provider",
)
: undefined
}
/>
)}
{/* Password field - only required for WEB auth type */}
{inviteForm.authType === "WEB" && (
<>
<TextInput
label={t("workspace.people.addMember.password")}
type="password"
placeholder={t(
"workspace.people.addMember.passwordPlaceholder",
)}
value={inviteForm.password}
onChange={(e) =>
setInviteForm({
...inviteForm,
password: e.currentTarget.value,
})
}
required
/>
<Checkbox
label={t(
"workspace.people.addMember.forcePasswordChange",
"Force password change on first login",
)}
checked={inviteForm.forceChange}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceChange: e.currentTarget.checked,
})
}
/>
<Checkbox
label={t(
"workspace.people.addMember.forceMFA",
"Force MFA setup on next login",
)}
checked={inviteForm.forceMFA}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceMFA: e.currentTarget.checked,
})
}
/>
</>
)}
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteForm.role}
onChange={(value) =>
setInviteForm({ ...inviteForm, role: value || "ROLE_USER" })
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) =>
setInviteForm({
...inviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
{/* Action Button */}
<Button
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === "email"
? t("workspace.people.emailInvite.submit", "Send Invites")
: inviteMode === "link"
? t("workspace.people.inviteLink.submit", "Generate Link")
: t("workspace.people.addMember.submit")}
</Button>
</Stack>
</Box>
</Modal>
);
}
@@ -0,0 +1,231 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { BASE_PATH } from "@app/constants/app";
type ImageSlide = {
src: string;
alt?: string;
cornerModelUrl?: string;
title?: string;
subtitle?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[];
showBackground?: boolean;
initialSeconds?: number;
slideSeconds?: number;
}) {
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
const mouse = useRef({ x: 0, y: 0 });
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return [];
return imageSlides.map(
(_, i) =>
(i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000,
);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
if (totalSlides <= 1) return;
const timeout = setTimeout(
() => {
setIndex((i) => (i + 1) % totalSlides);
},
durationsMs[index] ?? slideSeconds * 1000,
);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
function TiltImage({
src,
alt,
enabled,
maxDeg = 6,
}: {
src: string;
alt?: string;
enabled: boolean;
maxDeg?: number;
}) {
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
let raf = 0;
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg;
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = "translateY(-2rem)";
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [enabled, maxDeg]);
return (
<img
ref={imgRef}
src={src}
alt={alt ?? "Carousel slide"}
style={{
maxWidth: "86%",
maxHeight: "78%",
objectFit: "contain",
borderRadius: "18px",
background: "transparent",
transform: "translateY(-2rem)",
transition: "transform 80ms ease-out",
willChange: "transform",
transformOrigin: "50% 50%",
}}
/>
);
}
return (
<div
style={{
position: "relative",
overflow: "hidden",
width: "100%",
height: "100%",
}}
>
{showBackground && (
<img
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
alt="Background panel"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{/* Image slides */}
{imageSlides.map((s, idx) => (
<div
key={s.src}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 600ms ease",
opacity: index === idx ? 1 : 0,
perspective: "900px",
}}
>
{(s.title || s.subtitle) && (
<div
style={{
position: "absolute",
bottom: 24 + 32,
left: 0,
right: 0,
textAlign: "center",
padding: "0 2rem",
width: "100%",
}}
>
{s.title && (
<div
style={{
fontSize: 20,
fontWeight: 800,
color: "#ffffff",
textShadow: "0 2px 6px rgba(0,0,0,0.25)",
marginBottom: 6,
}}
>
{s.title}
</div>
)}
{s.subtitle && (
<div
style={{
fontSize: 13,
color: "rgba(255,255,255,0.92)",
textShadow: "0 1px 4px rgba(0,0,0,0.25)",
}}
>
{s.subtitle}
</div>
)}
</div>
)}
<TiltImage
src={s.src}
alt={s.alt}
enabled={index === idx && !!s.followMouseTilt}
maxDeg={s.tiltMaxDeg ?? 6}
/>
</div>
))}
{/* Dot navigation */}
<div
style={{
position: "absolute",
bottom: 16,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
gap: 10,
zIndex: 2,
}}
>
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
style={{
width: "10px",
height: "12px",
borderRadius: "50%",
border: "none",
cursor: "pointer",
backgroundColor:
i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "block",
flexShrink: 0,
}}
/>
))}
</div>
</div>
);
}
export default memo(LoginRightCarousel);
@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import { alert } from "@app/components/toast";
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
// Get current license key for authentication
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo.licenseKey) {
throw new Error(
"No license key found. Please activate a license first.",
);
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
returnUrl,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
setLoading(false);
} catch (error: unknown) {
console.error("Failed to open billing portal:", error);
alert({
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t("billing.manageBilling", "Manage Billing")}
</Button>
);
};
@@ -0,0 +1,41 @@
import React from "react";
import { Button, ButtonProps } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext";
interface UpdateSeatsButtonProps extends Omit<
ButtonProps,
"onClick" | "loading"
> {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
onSuccess,
onError,
...buttonProps
}) => {
const { t } = useTranslation();
const { openUpdateSeats, isLoading } = useUpdateSeats();
const handleClick = async () => {
await openUpdateSeats({
onSuccess,
onError,
});
};
return (
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
);
};
export default UpdateSeatsButton;
@@ -0,0 +1,226 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Button,
Text,
Alert,
Loader,
Stack,
Group,
NumberInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface UpdateSeatsModalProps {
opened: boolean;
onClose: () => void;
currentSeats: number;
minimumSeats: number;
_onSuccess?: () => void;
onError?: (error: string) => void;
onUpdateSeats?: (newSeats: number) => Promise<string>; // Returns billing portal URL
}
type UpdateState = {
status: "idle" | "loading" | "error";
error?: string;
};
const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
opened,
onClose,
currentSeats,
minimumSeats,
_onSuccess,
onError,
onUpdateSeats,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [newSeatCount, setNewSeatCount] = useState<number>(minimumSeats);
// Reset seat count when modal opens
useEffect(() => {
if (opened) {
setNewSeatCount(minimumSeats);
setState({ status: "idle" });
}
}, [opened, minimumSeats]);
const handleUpdateSeats = async () => {
if (!onUpdateSeats) {
setState({
status: "error",
error: "Update function not provided",
});
return;
}
if (newSeatCount < minimumSeats) {
setState({
status: "error",
error: t(
"billing.seatCountTooLow",
"Seat count must be at least {{minimum}} (current number of users)",
{
minimum: minimumSeats,
},
),
});
return;
}
if (newSeatCount === currentSeats) {
setState({
status: "error",
error: t(
"billing.seatCountUnchanged",
"Please select a different seat count",
),
});
return;
}
try {
setState({ status: "loading" });
// Call the update function (will call manage-billing)
const portalUrl = await onUpdateSeats(newSeatCount);
// Redirect to Stripe billing portal
console.log("Redirecting to Stripe billing portal:", portalUrl);
window.location.href = portalUrl;
// Note: No need to call onSuccess here since we're redirecting
// The return flow will handle success notification
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to update seat count";
setState({
status: "error",
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handleClose = () => {
setState({ status: "idle" });
setNewSeatCount(currentSeats);
onClose();
};
const renderContent = () => {
if (state.status === "loading") {
return (
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("billing.preparingUpdate", "Preparing seat update...")}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
{state.status === "error" && (
<Alert color="red" title={t("common.error", "Error")}>
{state.error}
</Alert>
)}
<Stack gap="md" mt="md">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t("billing.currentSeats", "Current Seats")}:
</Text>
<Text size="sm" fw={600}>
{currentSeats}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t("billing.minimumSeats", "Minimum Seats")}:
</Text>
<Text size="sm" c="dimmed">
{minimumSeats} {t("billing.basedOnUsers", "(current users)")}
</Text>
</Group>
</Stack>
<NumberInput
label={t("billing.newSeatCount", "New Seat Count")}
description={t(
"billing.newSeatCountDescription",
"Select the number of seats for your enterprise license",
)}
value={newSeatCount}
onChange={(value) =>
setNewSeatCount(typeof value === "number" ? value : minimumSeats)
}
min={minimumSeats}
max={10000}
step={1}
size="md"
styles={{
input: {
fontSize: "1.5rem",
fontWeight: 500,
textAlign: "center",
},
}}
/>
<Alert
color="blue"
title={t("billing.whatHappensNext", "What happens next?")}
>
<Text size="sm">
{t(
"billing.stripePortalRedirect",
"You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.",
)}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleUpdateSeats}
disabled={
newSeatCount === currentSeats || newSeatCount < minimumSeats
}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
</Group>
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t("billing.updateEnterpriseSeats", "Update Enterprise Seats")}
</Text>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default UpdateSeatsModal;
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import {
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
UPGRADE_BANNER_TEST_EVENT,
type UpgradeBannerTestPayload,
type UpgradeBannerTestScenario,
UPGRADE_BANNER_ALERT_EVENT,
} from "@core/constants/events";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { isOnboardingCompleted } from "@core/components/onboarding/orchestrator/onboardingStorage";
const FRIENDLY_LAST_SEEN_KEY = "upgradeBannerFriendlyLastShownAt";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
// Check if we're on an auth route (evaluated after hooks, used in render)
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
loginEnabled,
totalUsers,
userCountResolved,
userCountLoading,
effectiveIsAdmin: configIsAdmin,
hasPaidLicense,
licenseLoading,
freeTierLimit,
overFreeTierLimit,
weeklyActiveUsers,
scenarioKey,
} = useServerExperience();
const onboardingComplete = isOnboardingCompleted();
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === "undefined") return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
if (!lastShownRaw) return false;
const lastShown = parseInt(lastShownRaw, 10);
if (!Number.isFinite(lastShown)) return false;
return Date.now() - lastShown >= WEEK_IN_MS;
});
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] =
useState<UpgradeBannerTestScenario>(null);
useEffect(() => {
if (!isDev || typeof window === "undefined") {
return;
}
const handleTestEvent = (event: Event) => {
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
setTestScenario(detail?.scenario ?? null);
if (detail?.scenario === "friendly") {
setFriendlyVisible(true);
} else if (!detail?.scenario) {
setFriendlyVisible(false);
}
};
window.addEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
return () => {
window.removeEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
};
}, [isDev]);
const isAdmin = configIsAdmin;
const scenario = isDev ? testScenario : null;
const scenarioIsFriendly = scenario === "friendly";
const scenarioIsUrgentUser = scenario === "urgent-user";
const userCountKnown = typeof totalUsers === "number";
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
const isOverLimit = userCountKnown
? totalUsers > freeTierLimit
: overFreeTierLimit;
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== "unknown" && scenarioKey !== "licensed";
const derivedIsAdmin = scenarioProvidesInfo
? scenarioKey.includes("admin")
: isAdmin;
const derivedHasPaidLicense =
scenarioKey === "licensed"
? true
: scenarioKey === "unknown"
? hasPaidLicense
: false;
const derivedIsUnderLimit = scenarioProvidesInfo
? scenarioKey.includes("under-limit")
: isUnderLimit === true;
const derivedIsOverLimit = scenarioProvidesInfo
? scenarioKey.includes("over-limit")
: isOverLimit === true;
const effectiveIsAdmin = scenario
? scenarioIsUrgentUser
? false
: true
: derivedIsAdmin;
const effectiveTotalUsers =
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
const effectiveTotalUsersLoaded =
scenario != null ? true : baseTotalUsersLoaded;
const effectiveHasPaidLicense =
scenario != null ? false : derivedHasPaidLicense;
const effectiveIsUnderLimit =
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
const effectiveIsOverLimit =
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
const isDerivedAdmin = scenario
? !scenarioIsUrgentUser
: scenarioKey === "login-user-over-limit-no-license"
? false
: effectiveIsAdmin;
const shouldShowFriendlyBase = Boolean(
isDerivedAdmin &&
!effectiveHasPaidLicense &&
effectiveIsUnderLimit &&
effectiveTotalUsersLoaded,
);
const shouldShowUrgentBase = Boolean(
!effectiveHasPaidLicense &&
effectiveTotalUsersLoaded &&
(effectiveIsOverLimit ||
scenarioKey === "login-user-over-limit-no-license"),
);
const shouldEvaluateFriendly = scenario
? scenarioIsFriendly
: Boolean(
shouldShowFriendlyBase &&
!licenseLoading &&
effectiveTotalUsersLoaded &&
onboardingComplete,
);
// Urgent banner should always show when over-limit
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
: Boolean(shouldShowUrgentBase && !licenseLoading);
useEffect(() => {
if (scenario === "friendly") {
return;
}
if (!shouldEvaluateFriendly) {
setFriendlyVisible(false);
return;
}
if (friendlyVisible || typeof window === "undefined" || userCountLoading) {
return;
}
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
const now = Date.now();
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
setFriendlyVisible(due);
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const detail = shouldEvaluateUrgent
? {
active: true,
audience: effectiveIsAdmin ? "admin" : "user",
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
}
: { active: false };
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
);
}, [
shouldEvaluateUrgent,
effectiveIsAdmin,
effectiveTotalUsers,
scenario,
freeTierLimit,
]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, {
detail: { active: false },
}),
);
}
};
}, []);
const recordFriendlyLastShown = useCallback(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
}, []);
const handleUpgrade = () => {
recordFriendlyLastShown();
const hideBanner = () => setFriendlyVisible(false);
const navigateFallback = () => {
navigate("/settings/adminPlan");
hideBanner();
};
try {
openCheckout("server", {
minimumSeats: 1,
onSuccess: () => {
hideBanner();
},
onError: () => {
navigateFallback();
},
});
} catch (error) {
console.error(
"[UpgradeBanner] Failed to open checkout, redirecting instead",
error,
);
navigateFallback();
return;
}
// Keep legacy behavior so banner disappears once the user initiates checkout
hideBanner();
};
const handleFriendlyDismiss = () => {
recordFriendlyLastShown();
setFriendlyVisible(false);
};
const handleSeeInfo = () => {
if (typeof window === "undefined" || !effectiveIsAdmin) {
return;
}
const detail: ServerLicenseRequestPayload = {
licenseNotice: {
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
isOverLimit: effectiveIsOverLimit ?? false,
},
selfReportedAdmin: true,
deferUntilTourComplete: false,
};
window.dispatchEvent(
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
);
};
const renderUrgentBanner = () => {
if (!shouldEvaluateUrgent) {
return null;
}
const buttonText = effectiveIsAdmin
? t("upgradeBanner.seeInfo", "See info")
: undefined;
const attentionMessage = effectiveIsAdmin
? t(
"upgradeBanner.attentionBodyAdmin",
"Review the license requirements to keep this server compliant.",
)
: t(
"upgradeBanner.attentionBody",
"Your admin needs to sign in to see more info. Please contact them immediately.",
);
return (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t(
"upgradeBanner.attentionTitle",
"This server needs admin attention",
)}
message={attentionMessage}
buttonText={buttonText}
buttonIcon="info-rounded"
onButtonClick={buttonText ? handleSeeInfo : undefined}
dismissible={false}
minHeight={60}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
);
};
const suppressForNoLogin =
!loginEnabled ||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
// Don't show on auth routes or if neither banner type should show
// Also suppress entirely for no-login servers (treat them as regular users only)
// and, per request, never surface upgrade messaging there when WAU > 5.
if (
onAuthRoute ||
suppressForNoLogin ||
(!friendlyVisible && !shouldEvaluateUrgent)
) {
return null;
}
return (
<>
{friendlyVisible && (
<InfoBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t(
"upgradeBanner.message",
"Get the most out of Stirling PDF with unlimited users and advanced features.",
)}
buttonText={t("upgradeBanner.upgradeButton", "Upgrade Now")}
buttonIcon="upgrade-rounded"
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
show={friendlyVisible}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
borderColor="transparent"
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
buttonVariant="white"
buttonColor="blue"
minHeight={48}
compact
/>
)}
{renderUrgentBanner()}
</>
);
};
export default UpgradeBanner;
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function UpgradeBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<UpgradeBanner />);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
}
@@ -0,0 +1,47 @@
import { Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EnterpriseRequiredBannerProps {
show: boolean;
featureName: string;
}
/**
* Banner that explains enterprise-only features are in demo mode
*/
export default function EnterpriseRequiredBanner({
show,
featureName,
}: EnterpriseRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={
<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />
}
title={t(
"admin.settings.enterpriseRequired.title",
"Enterprise License Required",
)}
color="yellow"
variant="light"
styles={{
root: {
borderLeft: "4px solid var(--mantine-color-yellow-6)",
},
}}
>
<Text size="sm">
{t(
"admin.settings.enterpriseRequired.message",
"An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.",
{ featureName },
)}
</Text>
</Alert>
);
}
@@ -0,0 +1,54 @@
import { Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useNavigate } from "react-router-dom";
export function OverviewHeader() {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate("/login");
} catch (error) {
console.error("Logout error:", error);
}
};
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<div>
<Text fw={600} size="lg">
{t("config.overview.title", "Application Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t(
"config.overview.description",
"Current application settings and configuration details.",
)}
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,517 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
useConfigNavSections as useCoreConfigNavSections,
createConfigNavSections as createCoreConfigNavSections,
ConfigNavSection,
} from "@core/components/shared/config/configNavSections";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
import TeamsSection from "@app/components/shared/config/configSections/TeamsSection";
import AdminGeneralSection from "@app/components/shared/config/configSections/AdminGeneralSection";
import AdminSecuritySection from "@app/components/shared/config/configSections/AdminSecuritySection";
import AdminConnectionsSection from "@app/components/shared/config/configSections/AdminConnectionsSection";
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
import AdminDatabaseSection from "@app/components/shared/config/configSections/AdminDatabaseSection";
import AdminAdvancedSection from "@app/components/shared/config/configSections/AdminAdvancedSection";
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
/**
* Hook version of proprietary config nav sections with proper i18n support
*/
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
): ConfigNavSection[] => {
const { t } = useTranslation();
// Get the core sections (Preferences + Help)
const sections = useCoreConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
onRequestClose,
);
// Add account management under Preferences
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: "account",
label: t("account.accountSettings", "Account"),
icon: "person-rounded",
component: <AccountSection />,
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
const enableLoginTooltip = t(
"settings.tooltips.enableLoginFirst",
"Enable login mode first",
);
const requiresEnterpriseTooltip = t(
"settings.tooltips.requiresEnterprise",
"Requires Enterprise license",
);
// Workspace
sections.push({
title: t("settings.workspace.title", "Workspace"),
items: [
{
key: "people",
label: t("settings.workspace.people", "People"),
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "teams",
label: t("settings.workspace.teams", "Teams"),
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Configuration
sections.push({
title: t("settings.configuration.title", "Configuration"),
items: [
{
key: "adminGeneral",
label: t("settings.configuration.systemSettings", "System Settings"),
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminFeatures",
label: t("settings.configuration.features", "Features"),
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminStorageSharing",
label: t(
"settings.configuration.storageSharing",
"File Storage & Sharing",
),
icon: "storage-rounded",
component: <AdminStorageSharingSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
{
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminDatabase",
label: t("settings.configuration.database", "Database"),
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAdvanced",
label: t("settings.configuration.advanced", "Advanced"),
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t("settings.securityAuth.title", "Security & Authentication"),
items: [
{
key: "adminSecurity",
label: t("settings.securityAuth.security", "Security"),
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminConnections",
label: t("settings.securityAuth.connections", "Connections"),
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: t("settings.licensingAnalytics.title", "Licensing & Analytics"),
items: [
{
key: "adminPlan",
label: t("settings.licensingAnalytics.plan", "Plan"),
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAudit",
label: t("settings.licensingAnalytics.audit", "Audit"),
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
{
key: "adminUsage",
label: t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
],
});
// Policies & Privacy
sections.push({
title: t("settings.policiesPrivacy.title", "Policies & Privacy"),
items: [
{
key: "adminLegal",
label: t("settings.policiesPrivacy.legal", "Legal"),
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminPrivacy",
label: t("settings.policiesPrivacy.privacy", "Privacy"),
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
}
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: t("settings.developer.title", "Developer"),
items: [
{
key: "api-keys",
label: t("settings.developer.apiKeys", "API Keys"),
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
// Add Developer section after Preferences (or Workspace if it exists)
const insertIndex = isAdmin ? 2 : 1;
sections.splice(insertIndex, 0, developerSection);
}
return sections;
};
/**
* Deprecated: Use useConfigNavSections hook instead
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn(
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
);
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
// Add account management under Preferences
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: "account",
label: "Account",
icon: "person-rounded",
component: <AccountSection />,
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
// Workspace
sections.push({
title: "Workspace",
items: [
{
key: "people",
label: "People",
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Configuration
sections.push({
title: "Configuration",
items: [
{
key: "adminGeneral",
label: "System Settings",
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminFeatures",
label: "Features",
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminEndpoints",
label: "Endpoints",
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminDatabase",
label: "Database",
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAdvanced",
label: "Advanced",
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Security & Authentication
sections.push({
title: "Security & Authentication",
items: [
{
key: "adminSecurity",
label: "Security",
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminConnections",
label: "Connections",
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: "Licensing & Analytics",
items: [
{
key: "adminPlan",
label: "Plan",
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAudit",
label: "Audit",
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
{
key: "adminUsage",
label: "Usage Analytics",
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
],
});
// Policies & Privacy
sections.push({
title: "Policies & Privacy",
items: [
{
key: "adminLegal",
label: "Legal",
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminPrivacy",
label: "Privacy",
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
}
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: "Developer",
items: [
{
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
// Add Developer section after Preferences (or Workspace if it exists)
const insertIndex = isAdmin ? 2 : 1;
sections.splice(insertIndex, 0, developerSection);
}
return sections;
};
// Re-export types for convenience
export type {
ConfigNavSection,
ConfigNavItem,
ConfigColors,
} from "@core/components/shared/config/configNavSections";
@@ -0,0 +1,783 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Box,
Group,
Modal,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert as showToast } from "@app/components/toast";
import { useAuth } from "@app/auth/UseSession";
import { accountService } from "@app/services/accountService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { QRCodeSVG } from "qrcode.react";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { BASE_PATH } from "@app/constants/app";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
const AccountSection: React.FC = () => {
const { t } = useTranslation();
const { user, signOut } = useAuth();
const accountLogout = useAccountLogout();
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [currentPasswordForUsername, setCurrentPasswordForUsername] =
useState("");
const [newUsername, setNewUsername] = useState("");
const [usernameError, setUsernameError] = useState("");
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaDisableCode, setMfaDisableCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
const normalizeMfaCode = useCallback(
(value: string) => value.replace(/\D/g, "").slice(0, 6),
[],
);
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const authTypeFromMetadata = useMemo(() => {
const metadata = user?.app_metadata as
| { authType?: string; authenticationType?: string }
| undefined;
return metadata?.authenticationType ?? metadata?.authType;
}, [user?.app_metadata]);
const normalizedAuthType = useMemo(
() =>
(user?.authenticationType ?? authTypeFromMetadata ?? "").toLowerCase(),
[authTypeFromMetadata, user?.authenticationType],
);
const isSsoUser = useMemo(
() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType),
[normalizedAuthType],
);
const userIdentifier = useMemo(
() => user?.email || user?.username || "",
[user?.email, user?.username],
);
const redirectToLogin = useCallback(() => {
window.location.assign("/login");
}, []);
const handleLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSsoUser) {
setPasswordError(
t(
"settings.security.password.ssoDisabled",
"Password changes are managed by your identity provider.",
),
);
return;
}
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
if (newPassword !== confirmPassword) {
setPasswordError(
t("settings.security.password.mismatch", "New passwords do not match."),
);
return;
}
try {
setPasswordSubmitting(true);
setPasswordError("");
await accountService.changePassword(currentPassword, newPassword);
showToast({
alertType: "success",
title: t(
"settings.security.password.success",
"Password updated successfully. Please sign in again.",
),
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setPasswordError(
axiosError.response?.data?.message ||
t(
"settings.security.password.error",
"Unable to update password. Please verify your current password and try again.",
),
);
} finally {
setPasswordSubmitting(false);
}
};
useEffect(() => {
const fetchAccountData = async () => {
setChangeButtonDisabled(true);
try {
const data = await accountService
.getAccountData()
.then((data) => data)
.finally(() => {
setChangeButtonDisabled(false);
});
setMfaEnabled(data.mfaEnabled ?? false);
} catch {
// ignore fetch errors for account data
console.warn("Failed to fetch account data");
} finally {
setChangeButtonDisabled(false);
}
};
void fetchAccountData();
}, []);
const handleStartMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
setMfaSetupModalOpen(true);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.setupFailed",
"Unable to start two-factor setup. Please try again.",
),
);
} finally {
setMfaLoading(false);
}
}, [t]);
const handleEnableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
setMfaLoading(true);
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setMfaEnabled(true);
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode("");
showToast({
alertType: "success",
title: t("account.mfa.enabled", "Two-factor authentication enabled."),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.enableFailed",
"Unable to enable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
}
},
[mfaSetupCode, t],
);
const handleDisableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaDisableCode.trim()) {
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
setMfaLoading(true);
setMfaError("");
await accountService.disableMfa(mfaDisableCode.trim());
setMfaEnabled(false);
setMfaDisableModalOpen(false);
setMfaDisableCode("");
showToast({
alertType: "success",
title: t(
"account.mfa.disabled",
"Two-factor authentication disabled.",
),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.disableFailed",
"Unable to disable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
}
},
[mfaDisableCode, t],
);
const handleCloseMfaSetupModal = useCallback(async () => {
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode("");
setMfaError("");
try {
await accountService.cancelMfaSetup();
} catch {
console.warn("Failed to clear pending MFA setup");
}
}, []);
const handleCloseMfaDisableModal = useCallback(() => {
setMfaDisableModalOpen(false);
setMfaDisableCode("");
setMfaError("");
}, []);
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSsoUser) {
setUsernameError(
t(
"changeCreds.ssoManaged",
"Your account is managed by your identity provider.",
),
);
return;
}
if (!currentPasswordForUsername || !newUsername) {
setUsernameError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
try {
setUsernameSubmitting(true);
setUsernameError("");
await accountService.changeUsername(
newUsername,
currentPasswordForUsername,
);
showToast({
alertType: "success",
title: t("changeCreds.credsUpdated", "Account updated"),
body: t(
"changeCreds.description",
"Changes saved. Please log in again.",
),
});
setNewUsername("");
setCurrentPasswordForUsername("");
setUsernameModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setUsernameError(
axiosError.response?.data?.message ||
t(
"changeCreds.error",
"Unable to update username. Please verify your password and try again.",
),
);
} finally {
setUsernameSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Text fw={600} size="lg">
{t("account.accountSettings", "Account")}
</Text>
<Text size="sm" c="dimmed">
{t("changeCreds.header", "Update Your Account Details")}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{userIdentifier
? t("settings.general.user", "User") + ": " + userIdentifier
: t("account.accountSettings", "Account Settings")}
</Text>
<Stack gap="xs">
{isSsoUser && (
<Alert
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
color="blue"
variant="light"
>
{t(
"changeCreds.ssoManaged",
"Your account is managed by your identity provider.",
)}
</Alert>
)}
<Group gap="sm" wrap="wrap">
{!isSsoUser && (
<Button
leftSection={<LocalIcon icon="key-rounded" />}
onClick={() => setPasswordModalOpen(true)}
>
{t("settings.security.password.update", "Update password")}
</Button>
)}
{!isSsoUser && (
<Button
variant="light"
leftSection={<LocalIcon icon="edit-rounded" />}
onClick={() => setUsernameModalOpen(true)}
>
{t("account.changeUsername", "Change username")}
</Button>
)}
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="logout-rounded" />}
onClick={handleLogout}
>
{t("settings.general.logout", "Log out")}
</Button>
</Group>
</Stack>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={600}>
{t("account.mfa.title", "Two-factor authentication")}
</Text>
<Text size="sm" c="dimmed">
{t(
"account.mfa.description",
"Add an extra layer of security to your account.",
)}
</Text>
{isSsoUser ? (
<Alert
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
color="blue"
variant="light"
>
{t(
"account.mfa.ssoManaged",
"Two-factor authentication for this account is managed by your identity provider.",
)}
</Alert>
) : (
<Group gap="sm" wrap="wrap">
{!mfaEnabled ? (
<Button
leftSection={
<LocalIcon icon="check-circle-outline-rounded" />
}
onClick={handleStartMfaSetup}
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t(
"account.mfa.enableButton",
"Enable two-factor authentication",
)}
</Button>
) : (
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="close-rounded" />}
onClick={() => {
setMfaError("");
setMfaDisableCode("");
setMfaDisableModalOpen(true);
}}
disabled={changeButtonDisabled}
>
{t(
"account.mfa.disableButton",
"Disable two-factor authentication",
)}
</Button>
)}
</Group>
)}
</Stack>
</Paper>
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
title={t("settings.security.title", "Change password")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handlePasswordSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"settings.security.password.subtitle",
"Change your password. You will be logged out after updating.",
)}
</Text>
{passwordError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{passwordError}
</Alert>
)}
<PasswordInput
label={t(
"settings.security.password.current",
"Current password",
)}
placeholder={t(
"settings.security.password.currentPlaceholder",
"Enter your current password",
)}
value={currentPassword}
onChange={(event) =>
setCurrentPassword(event.currentTarget.value)
}
required
/>
<PasswordInput
label={t("settings.security.password.new", "New password")}
placeholder={t(
"settings.security.password.newPlaceholder",
"Enter a new password",
)}
value={newPassword}
onChange={(event) => setNewPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t(
"settings.security.password.confirm",
"Confirm new password",
)}
placeholder={t(
"settings.security.password.confirmPlaceholder",
"Re-enter your new password",
)}
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setPasswordModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
loading={passwordSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("settings.security.password.update", "Update password")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={mfaSetupModalOpen}
onClose={handleCloseMfaSetupModal}
title={t("account.mfa.setupTitle", "Set up two-factor authentication")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleEnableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"account.mfa.setupDescription",
"Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.",
)}
</Text>
{mfaSetupData && (
<Stack gap="sm" align="center">
<Box
style={{
padding: "1.5rem",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG
value={mfaSetupData.otpauthUri || ""}
size={180}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 40,
width: 40,
excavate: true,
}}
/>
</Box>
<Text size="sm" c="dimmed">
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
<strong>{mfaSetupData.secret}</strong>
</Text>
<Text size="xs" c="orange">
{t(
"account.mfa.secretWarning",
"Keep this key private. Anyone with access can generate valid authentication codes.",
)}
</Text>
</Stack>
)}
{mfaError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
<TextInput
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaSetupCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaSetupModal}>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={mfaLoading}>
{t("account.mfa.confirmEnable", "Enable")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={mfaDisableModalOpen}
onClose={handleCloseMfaDisableModal}
title={t(
"account.mfa.disableTitle",
"Disable two-factor authentication",
)}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleDisableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"account.mfa.disableDescription",
"Enter a valid authentication code to disable two-factor authentication.",
)}
</Text>
{mfaError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
<TextInput
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaDisableCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaDisableModal}>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" color="red" loading={mfaLoading}>
{t("account.mfa.confirmDisable", "Disable")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
title={t("account.changeUsername", "Change username")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleUsernameSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"changeCreds.changeUsername",
"Update your username. You will be logged out after updating.",
)}
</Text>
{usernameError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{usernameError}
</Alert>
)}
<TextInput
label={t("changeCreds.newUsername", "New Username")}
placeholder={t("changeCreds.newUsername", "New Username")}
value={newUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setNewUsername(event.currentTarget.value)
}
required
/>
<PasswordInput
label={t("changeCreds.oldPassword", "Current Password")}
placeholder={t("changeCreds.oldPassword", "Current Password")}
value={currentPasswordForUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCurrentPasswordForUsername(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setUsernameModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
loading={usernameSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("common.save", "Save")}
</Button>
</Group>
</Stack>
</form>
</Modal>
</Stack>
);
};
export default AccountSection;
@@ -0,0 +1,283 @@
import React, { Suspense, lazy, useState, useEffect } from "react";
import { isAxiosError } from "axios";
import {
Tabs,
Loader,
Alert,
Stack,
Text,
Button,
Accordion,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import auditService, {
AuditSystemStatus as AuditStatus,
} from "@app/services/auditService";
import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus";
import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards";
// AuditChartsSection pulls in recharts (~200 kB gzip). It's only rendered when
// the user expands the "Events Over Time" accordion on this page, so we keep
// the rest of the audit page eager and lazy-load just the charts.
const AuditChartsSection = lazy(
() =>
import("@app/components/shared/config/configSections/audit/AuditChartsSection"),
);
import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable";
import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection";
import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
import LocalIcon from "@app/components/shared/LocalIcon";
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">(
"week",
);
useEffect(() => {
const fetchSystemStatus = async () => {
try {
setLoading(true);
setError(null);
const status = await auditService.getSystemStatus();
setSystemStatus(status);
} catch (err: unknown) {
// Check if this is a permission/license error (403/404)
const status = isAxiosError(err) ? err.response?.status : undefined;
if (status === 403 || status === 404) {
setError("enterprise-license-required");
} else {
setError(
err instanceof Error
? err.message
: "Failed to load audit system status",
);
}
} finally {
setLoading(false);
}
};
if (!showDemoData) {
fetchSystemStatus();
} else {
// Provide example audit system status when running in demo mode
setError(null);
setSystemStatus({
enabled: true,
level: "INFO",
retentionDays: 90,
totalEvents: 1234,
pdfMetadataEnabled: true,
captureFileHash: true,
capturePdfAuthor: true,
captureOperationResults: false,
});
setLoading(false);
}
}, [loginEnabled, showDemoData]);
// Override loading state when showing demo data
const actualLoading = showDemoData ? false : loading;
if (actualLoading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
}
if (error) {
if (error === "enterprise-license-required") {
return (
<Alert
color="blue"
title={t("audit.enterpriseRequired", "Enterprise License Required")}
>
{t(
"audit.enterpriseRequiredMessage",
"The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.",
)}
</Alert>
);
}
return (
<Alert
color="red"
title={t("audit.error.title", "Error loading audit system")}
>
{error}
</Alert>
);
}
if (!systemStatus) {
return (
<Alert
color="yellow"
title={t("audit.notAvailable", "Audit system not available")}
>
{t(
"audit.notAvailableMessage",
"The audit system is not configured or not available.",
)}
</Alert>
);
}
const isEnabled = loginEnabled && hasEnterpriseLicense;
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t("settings.licensingAnalytics.audit", "Audit")}
/>
{/* Info banner about audit settings */}
{isEnabled && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t("audit.configureAudit", "Configure Audit Logging")}
color="blue"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
"audit.configureAuditMessage",
"Adjust audit logging level, retention period, and other settings in the Security & Authentication section.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("audit.goToSettings", "Go to Audit Settings")}
</Button>
</Stack>
</Alert>
)}
<AuditSystemStatus status={systemStatus} />
{systemStatus?.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
{t("audit.tabs.dashboard", "Dashboard")}
</Tabs.Tab>
<Tabs.Tab value="events" disabled={!isEnabled}>
{t("audit.tabs.events", "Audit Events")}
</Tabs.Tab>
<Tabs.Tab value="export" disabled={!isEnabled}>
{t("audit.tabs.export", "Export")}
</Tabs.Tab>
<Tabs.Tab value="clearData" disabled={!isEnabled}>
{t("audit.tabs.clearData", "Clear Data")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<Stack gap="lg">
{/* Stats Cards - Always Visible */}
<AuditStatsCards
loginEnabled={isEnabled}
timePeriod={timePeriod}
/>
{/* Charts in Accordion - Collapsible */}
<Accordion defaultValue={["events-over-time"]} multiple>
<Accordion.Item value="events-over-time">
<Accordion.Control>
{t("audit.charts.overTime", "Events Over Time")}
</Accordion.Control>
<Accordion.Panel>
<Suspense
fallback={
<Center style={{ padding: "2rem" }}>
<Loader />
</Center>
}
>
<AuditChartsSection
loginEnabled={isEnabled}
timePeriod={timePeriod}
onTimePeriodChange={setTimePeriod}
/>
</Suspense>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
/>
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
captureOperationResults={systemStatus?.captureOperationResults}
/>
</Tabs.Panel>
<Tabs.Panel value="clearData" pt="md">
<AuditClearDataSection loginEnabled={isEnabled} />
</Tabs.Panel>
</Tabs>
) : (
<Alert
color="blue"
title={t("audit.disabled", "Audit logging is disabled")}
>
{t(
"audit.disabledMessage",
"Enable audit logging in your application configuration to track system events.",
)}
</Alert>
)}
</Stack>
);
};
export default AdminAuditSection;
@@ -0,0 +1,995 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
Stack,
Text,
Loader,
Group,
Divider,
Paper,
Switch,
Badge,
Anchor,
Select,
Collapse,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { Z_INDEX_CONFIG_MODAL } from "@app/styles/zIndex";
import ProviderCard from "@app/components/shared/config/configSections/ProviderCard";
import {
Provider,
useAllProviders,
} from "@app/components/shared/config/configSections/providerDefinitions";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeedbackFlags {
noValidDocument?: boolean;
errorProcessing?: boolean;
errorMessage?: boolean;
}
interface FeedbackSettings {
general?: { enabled?: boolean };
channel?: FeedbackFlags;
user?: FeedbackFlags;
}
interface TelegramSettingsData {
enabled?: boolean;
botToken?: string;
botUsername?: string;
pipelineInboxFolder?: string;
customFolderSuffix?: boolean;
enableAllowUserIDs?: boolean;
allowUserIDs?: number[];
enableAllowChannelIDs?: boolean;
allowChannelIDs?: number[];
processingTimeoutSeconds?: number;
pollingIntervalMillis?: number;
feedback?: FeedbackSettings;
}
interface MailSettings {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
interface GoogleDriveSettings {
enabled?: boolean;
clientId?: string;
apiKey?: string;
appId?: string;
}
interface OAuth2GenericSettings {
enabled?: boolean;
provider?: string;
issuer?: string;
clientId?: string;
clientSecret?: string;
scopes?: string;
useAsUsername?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
}
interface Saml2Settings {
enabled?: boolean;
provider?: string;
registrationId?: string;
idpMetadataUri?: string;
idpSingleLoginUrl?: string;
idpSingleLogoutUrl?: string;
idpIssuer?: string;
idpCert?: string;
privateKey?: string;
spCert?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
}
interface OAuth2ClientSettings {
clientId?: string;
clientSecret?: string;
scopes?: string;
useAsUsername?: string;
issuer?: string;
}
type ProviderSettings =
| MailSettings
| TelegramSettingsData
| GoogleDriveSettings
| OAuth2GenericSettings
| Saml2Settings
| OAuth2ClientSettings;
interface ConnectionsSettingsData {
oauth2?: {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
client?: Record<string, OAuth2ClientSettings>;
};
saml2?: Saml2Settings;
mail?: MailSettings;
telegram?: TelegramSettingsData;
ssoAutoLogin?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
mobileScannerImageResolution?: string;
mobileScannerPageFormat?: string;
mobileScannerStretchToFit?: boolean;
googleDriveEnabled?: boolean;
googleDriveClientId?: string;
googleDriveApiKey?: string;
googleDriveAppId?: string;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, closeRestartModal, restartServer } =
useRestartServer();
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: "connections",
fetchTransformer: async (): Promise<
ConnectionsSettingsData & { _pending?: Record<string, unknown> }
> => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get(
"/api/v1/admin/settings/section/security",
);
const securityData = securityResponse.data || {};
// Fetch mail settings
const mailResponse = await apiClient.get(
"/api/v1/admin/settings/section/mail",
);
const mailData = mailResponse.data || {};
// Fetch premium settings for SSO Auto Login
const premiumResponse = await apiClient.get(
"/api/v1/admin/settings/section/premium",
);
const premiumData = premiumResponse.data || {};
// Fetch Telegram settings
const telegramResponse = await apiClient.get(
"/api/v1/admin/settings/section/telegram",
);
const telegramData = telegramResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: ConnectionsSettingsData & {
_pending?: Record<string, unknown>;
} = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
telegram: telegramData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false,
mobileScannerConvertToPdf:
systemData.mobileScannerSettings?.convertToPdf !== false,
mobileScannerImageResolution:
systemData.mobileScannerSettings?.imageResolution || "full",
mobileScannerPageFormat:
systemData.mobileScannerSettings?.pageFormat || "A4",
mobileScannerStretchToFit:
systemData.mobileScannerSettings?.stretchToFit || false,
googleDriveEnabled:
premiumData.proFeatures?.googleDrive?.enabled || false,
googleDriveClientId:
premiumData.proFeatures?.googleDrive?.clientId || "",
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || "",
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || "",
};
// Merge pending blocks from all endpoints
const pendingBlock: Record<string, unknown> = {};
if (securityData._pending?.oauth2) {
pendingBlock.oauth2 = securityData._pending.oauth2;
}
if (securityData._pending?.saml2) {
pendingBlock.saml2 = securityData._pending.saml2;
}
if (mailData._pending) {
pendingBlock.mail = mailData._pending;
}
if (telegramData._pending) {
pendingBlock.telegram = telegramData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin =
premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner =
systemData._pending.enableMobileScanner;
}
if (
systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined
) {
pendingBlock.mobileScannerConvertToPdf =
systemData._pending.mobileScannerSettings.convertToPdf;
}
if (
systemData._pending?.mobileScannerSettings?.imageResolution !==
undefined
) {
pendingBlock.mobileScannerImageResolution =
systemData._pending.mobileScannerSettings.imageResolution;
}
if (
systemData._pending?.mobileScannerSettings?.pageFormat !== undefined
) {
pendingBlock.mobileScannerPageFormat =
systemData._pending.mobileScannerSettings.pageFormat;
}
if (
systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined
) {
pendingBlock.mobileScannerStretchToFit =
systemData._pending.mobileScannerSettings.stretchToFit;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.enabled !== undefined
) {
pendingBlock.googleDriveEnabled =
premiumData._pending.proFeatures.googleDrive.enabled;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.clientId !== undefined
) {
pendingBlock.googleDriveClientId =
premiumData._pending.proFeatures.googleDrive.clientId;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.apiKey !== undefined
) {
pendingBlock.googleDriveApiKey =
premiumData._pending.proFeatures.googleDrive.apiKey;
}
if (premiumData._pending?.proFeatures?.googleDrive?.appId !== undefined) {
pendingBlock.googleDriveAppId =
premiumData._pending.proFeatures.googleDrive.appId;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (currentSettings: ConnectionsSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
// Build delta for oauth2 settings
if (currentSettings.oauth2) {
Object.keys(currentSettings.oauth2).forEach((key) => {
if (key !== "client") {
deltaSettings[`security.oauth2.${key}`] = (
currentSettings.oauth2 as Record<string, unknown>
)[key];
}
});
// Build delta for specific OAuth2 providers
const oauth2Client = currentSettings.oauth2.client;
if (oauth2Client) {
Object.keys(oauth2Client).forEach((providerId) => {
const providerSettings = oauth2Client[providerId] as Record<
string,
unknown
>;
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${providerId}.${key}`] =
providerSettings[key];
});
});
}
}
// Build delta for saml2 settings
if (currentSettings.saml2) {
const saml2 = currentSettings.saml2 as Record<string, unknown>;
Object.keys(saml2).forEach((key) => {
deltaSettings[`security.saml2.${key}`] = saml2[key];
});
}
// Mail settings
if (currentSettings.mail) {
const mail = currentSettings.mail as Record<string, unknown>;
Object.keys(mail).forEach((key) => {
deltaSettings[`mail.${key}`] = mail[key];
});
}
// Telegram settings
if (currentSettings.telegram) {
const telegram = currentSettings.telegram as Record<string, unknown>;
Object.keys(telegram).forEach((key) => {
deltaSettings[`telegram.${key}`] = telegram[key];
});
}
// SSO Auto Login
if (currentSettings?.ssoAutoLogin !== undefined) {
deltaSettings["premium.proFeatures.ssoAutoLogin"] =
currentSettings.ssoAutoLogin;
}
// Mobile Scanner settings
if (currentSettings?.enableMobileScanner !== undefined) {
deltaSettings["system.enableMobileScanner"] =
currentSettings.enableMobileScanner;
}
if (currentSettings?.mobileScannerConvertToPdf !== undefined) {
deltaSettings["system.mobileScannerSettings.convertToPdf"] =
currentSettings.mobileScannerConvertToPdf;
}
if (currentSettings?.mobileScannerImageResolution !== undefined) {
deltaSettings["system.mobileScannerSettings.imageResolution"] =
currentSettings.mobileScannerImageResolution;
}
if (currentSettings?.mobileScannerPageFormat !== undefined) {
deltaSettings["system.mobileScannerSettings.pageFormat"] =
currentSettings.mobileScannerPageFormat;
}
if (currentSettings?.mobileScannerStretchToFit !== undefined) {
deltaSettings["system.mobileScannerSettings.stretchToFit"] =
currentSettings.mobileScannerStretchToFit;
}
// Google Drive settings
if (currentSettings?.googleDriveEnabled !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.enabled"] =
currentSettings.googleDriveEnabled;
}
if (currentSettings?.googleDriveClientId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.clientId"] =
currentSettings.googleDriveClientId;
}
if (currentSettings?.googleDriveApiKey !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.apiKey"] =
currentSettings.googleDriveApiKey;
}
if (currentSettings?.googleDriveAppId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.appId"] =
currentSettings.googleDriveAppId;
}
return {
sectionData: {},
deltaSettings,
};
},
});
const { settings, setSettings, loading, fetchSettings, isFieldPending } =
adminSettings;
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
markSaved();
try {
await adminSettings.saveSettings();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === "saml2") {
return settings?.saml2?.enabled === true;
}
if (provider.id === "smtp") {
return settings?.mail?.enabled === true;
}
if (provider.id === "telegram") {
return settings?.telegram?.enabled === true;
}
if (provider.id === "googledrive") {
return settings?.googleDriveEnabled === true;
}
if (provider.id === "oauth2-generic") {
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!providerSettings?.clientId;
};
const getProviderSettings = (provider: Provider): ProviderSettings => {
if (provider.id === "saml2") {
return settings?.saml2 || {};
}
if (provider.id === "smtp") {
return settings?.mail || {};
}
if (provider.id === "telegram") {
return settings?.telegram || {};
}
if (provider.id === "googledrive") {
const gd: GoogleDriveSettings = {
enabled: settings?.googleDriveEnabled,
clientId: settings?.googleDriveClientId,
apiKey: settings?.googleDriveApiKey,
appId: settings?.googleDriveAppId,
};
return gd;
}
if (provider.id === "oauth2-generic") {
const generic: OAuth2GenericSettings = {
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
issuer: settings?.oauth2?.issuer,
clientId: settings?.oauth2?.clientId,
clientSecret: settings?.oauth2?.clientSecret,
scopes: settings?.oauth2?.scopes,
useAsUsername: settings?.oauth2?.useAsUsername,
autoCreateUser: settings?.oauth2?.autoCreateUser,
blockRegistration: settings?.oauth2?.blockRegistration,
};
return generic;
}
// Specific OAuth2 provider settings
return settings?.oauth2?.client?.[provider.id] || {};
};
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
const availableProviders = allProviders.filter(
(p) => !isProviderConfigured(p),
);
const updateProviderSettings = (
provider: Provider,
updatedSettings: Record<string, unknown>,
) => {
if (provider.id === "smtp") {
setSettings({ ...settings, mail: updatedSettings as MailSettings });
} else if (provider.id === "telegram") {
setSettings({
...settings,
telegram: updatedSettings as TelegramSettingsData,
});
} else if (provider.id === "googledrive") {
const gd = updatedSettings as GoogleDriveSettings;
setSettings({
...settings,
googleDriveEnabled: gd.enabled,
googleDriveClientId: gd.clientId,
googleDriveApiKey: gd.apiKey,
googleDriveAppId: gd.appId,
});
} else if (provider.id === "saml2") {
setSettings({ ...settings, saml2: updatedSettings as Saml2Settings });
} else if (provider.id === "oauth2-generic") {
const generic = updatedSettings as OAuth2GenericSettings;
setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } });
} else {
// Specific OAuth2 provider
const clientSettings = updatedSettings as OAuth2ClientSettings;
setSettings({
...settings,
oauth2: {
...settings.oauth2,
client: {
...settings.oauth2?.client,
[provider.id]: clientSettings,
},
},
});
}
};
return (
<div className="settings-section-container">
<Stack gap="xl" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
{t("admin.settings.connections.title", "Connections")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.connections.description",
"Configure external authentication providers like OAuth2 and SAML.",
)}
</Text>
</div>
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.connections.ssoAutoLogin.label",
"SSO Auto Login",
)}
</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
</Group>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.ssoAutoLogin.enable",
"Enable SSO Auto Login",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.ssoAutoLogin.description",
"Automatically redirect to SSO login when authentication is required",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({
...settings,
ssoAutoLogin: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("ssoAutoLogin")} />
</Group>
</div>
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon
icon="qr-code-rounded"
width="1.25rem"
height="1.25rem"
/>
<Text fw={600} size="sm">
{t(
"admin.settings.connections.mobileScanner.label",
"Mobile Phone Upload",
)}
</Text>
</Group>
{/* Documentation Link */}
<Anchor
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
target="_blank"
size="xs"
c="blue"
>
{t(
"admin.settings.connections.documentation",
"View documentation",
)}{" "}
</Anchor>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.mobileScanner.enable",
"Enable QR Code Upload",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.mobileScanner.description",
"Allow users to upload files from mobile devices by scanning a QR code",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t(
"admin.settings.connections.mobileScanner.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.connections.mobileScanner.link",
"Configure in System Settings",
)}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({
...settings,
enableMobileScanner: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableMobileScanner")} />
</Group>
</div>
{/* Mobile Scanner Settings - Only show when enabled */}
<Collapse in={settings?.enableMobileScanner || false}>
<Stack
gap="md"
mt="md"
ml="lg"
style={{
borderLeft: "2px solid var(--mantine-color-gray-3)",
paddingLeft: "1rem",
}}
>
{/* Convert to PDF */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerConvertToPdf",
"Convert Images to PDF",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerConvertToPdfDesc",
"Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerConvertToPdf: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge
show={isFieldPending("mobileScannerConvertToPdf")}
/>
</Group>
</div>
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
{settings?.mobileScannerConvertToPdf !== false && (
<>
{/* Image Resolution */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerImageResolution",
"Image Resolution",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerImageResolutionDesc",
'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.',
)}
</Text>
<Group gap="xs">
<Select
value={
settings?.mobileScannerImageResolution || "full"
}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerImageResolution: value || "full",
});
}}
data={[
{
value: "full",
label: t(
"admin.settings.connections.imageResolutionFull",
"Full (Original Size)",
),
},
{
value: "reduced",
label: t(
"admin.settings.connections.imageResolutionReduced",
"Reduced (Max 1200px)",
),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge
show={isFieldPending("mobileScannerImageResolution")}
/>
</Group>
</div>
{/* Page Format */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerPageFormat",
"Page Format",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerPageFormatDesc",
'PDF page size for converted images. "Keep" uses original image dimensions.',
)}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerPageFormat || "A4"}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerPageFormat: value || "A4",
});
}}
data={[
{
value: "keep",
label: t(
"admin.settings.connections.pageFormatKeep",
"Keep (Original Dimensions)",
),
},
{
value: "A4",
label: t(
"admin.settings.connections.pageFormatA4",
"A4 (210×297mm)",
),
},
{
value: "letter",
label: t(
"admin.settings.connections.pageFormatLetter",
"Letter (8.5×11in)",
),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge
show={isFieldPending("mobileScannerPageFormat")}
/>
</Group>
</div>
{/* Stretch to Fit */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerStretchToFit",
"Stretch to Fit",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerStretchToFitDesc",
"Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerStretchToFit || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerStretchToFit: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge
show={isFieldPending("mobileScannerStretchToFit")}
/>
</Group>
</div>
</>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<div>
<Text fw={600} size="md" mb="md">
{t(
"admin.settings.connections.linkedServices",
"Linked Services",
)}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
</Stack>
</div>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t(
"admin.settings.connections.unlinkedServices",
"Unlinked Services",
)}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={adminSettings.saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,435 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Paper,
Text,
Loader,
Group,
MultiSelect,
Switch,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface UISettingsData {
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
}
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: "endpoints",
});
const {
settings: uiSettings,
setSettings: setUiSettings,
loading: uiLoading,
saving: uiSaving,
fetchSettings: fetchUiSettings,
saveSettings: saveUiSettings,
isFieldPending: isUiFieldPending,
} = useAdminSettings<UISettingsData>({
sectionName: "ui",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
fetchUiSettings();
}
}, [loginEnabled, fetchSettings, fetchUiSettings]);
const {
isDirty: isEndpointsDirty,
resetToSnapshot: resetEndpointsSnapshot,
markSaved: markEndpointsSaved,
} = useSettingsDirty(settings, loading);
const {
isDirty: isUiDirty,
resetToSnapshot: resetUiSnapshot,
markSaved: markUiSaved,
} = useSettingsDirty(uiSettings, uiLoading);
const isDirty = isEndpointsDirty || isUiDirty;
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
if (isEndpointsDirty) {
markEndpointsSaved();
await saveSettings();
}
if (isUiDirty) {
markUiSaved();
await saveUiSettings();
}
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
if (isEndpointsDirty) {
const original = resetEndpointsSnapshot();
setSettings(original);
}
if (isUiDirty) {
const original = resetUiSnapshot();
setUiSettings(original);
}
}, [
isEndpointsDirty,
isUiDirty,
resetEndpointsSnapshot,
resetUiSnapshot,
setSettings,
setUiSettings,
]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading || uiLoading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
// Complete list of all endpoints from frontend tool registry (alphabetical)
const commonEndpoints = [
"add-attachments",
"add-image",
"add-page-numbers",
"add-password",
"add-stamp",
"add-watermark",
"adjust-contrast",
"auto-redact",
"auto-rename",
"auto-split-pdf",
"automate",
"booklet-imposition",
"cert-sign",
"compare",
"compress-pdf",
"crop",
"dev-airgapped-docs",
"dev-api-docs",
"dev-folder-scanning-docs",
"dev-sso-guide-docs",
"edit-table-of-contents",
"eml-to-pdf",
"extract-image-scans",
"extract-images",
"file-to-pdf",
"flatten",
"get-info-on-pdf",
"handleData",
"html-to-pdf",
"img-to-pdf",
"markdown-to-pdf",
"merge-pdfs",
"multi-page-layout",
"multi-tool",
"ocr-pdf",
"overlay-pdf",
"pdf-to-csv",
"pdf-to-xlsx",
"pdf-to-epub",
"pdf-to-html",
"pdf-to-img",
"pdf-to-markdown",
"pdf-to-pdfa",
"pdf-to-presentation",
"pdf-to-single-page",
"pdf-to-text",
"pdf-to-word",
"pdf-to-xml",
"pipeline",
"rearrange-pages",
"remove-annotations",
"remove-blanks",
"remove-cert-sign",
"remove-image-pdf",
"remove-pages",
"remove-password",
"repair",
"replace-invert-pdf",
"rotate-pdf",
"sanitize-pdf",
"scale-pages",
"scanner-effect",
"show-javascript",
"sign",
"split-by-size-or-count",
"split-pages",
"split-pdf-by-chapters",
"split-pdf-by-sections",
"text-editor-pdf",
"unlock-pdf-forms",
"update-metadata",
"validate-signature",
"view-pdf",
];
// Complete list of functional and tool groups from EndpointConfiguration.java
const commonGroups = [
// Functional Groups
"PageOps",
"Convert",
"Security",
"Other",
"Advance",
"Automation",
"DeveloperTools",
"DeveloperDocs",
// Tool Groups
"CLI",
"Python",
"OpenCV",
"LibreOffice",
"Unoconvert",
"Java",
"Javascript",
"qpdf",
"Ghostscript",
"ImageMagick",
"tesseract",
"OCRmyPDF",
"Weasyprint",
"Pdftohtml",
"Calibre",
"FFmpeg",
"veraPDF",
"rar",
];
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.endpoints.title", "API Endpoints")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.endpoints.description",
"Control which API endpoints and endpoint groups are available.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.endpoints.management", "Endpoint Management")}
</Text>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.toRemove.label",
"Disabled Endpoints",
)}
</span>
<PendingBadge show={isFieldPending("toRemove")} />
</Group>
}
description={t(
"admin.settings.endpoints.toRemove.description",
"Select individual endpoints to disable",
)}
value={settings.toRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map((endpoint) => ({
value: endpoint,
label: endpoint,
}))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.groupsToRemove.label",
"Disabled Endpoint Groups",
)}
</span>
<PendingBadge show={isFieldPending("groupsToRemove")} />
</Group>
}
description={t(
"admin.settings.endpoints.groupsToRemove.description",
"Select endpoint groups to disable",
)}
value={settings.groupsToRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map((group) => ({
value: group,
label: group,
}))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">
{t(
"admin.settings.endpoints.userDefaults",
"User Preference Defaults",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.endpoints.userDefaultsDescription",
"Set default values for user preferences. Users can override these in their personal settings.",
)}
</Text>
</div>
<Switch
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.defaultHideUnavailableTools.label",
"Hide unavailable tools by default",
)}
</span>
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableTools")}
/>
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableTools.description",
"Remove disabled tools instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableTools || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({
...uiSettings,
defaultHideUnavailableTools: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
<Switch
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.defaultHideUnavailableConversions.label",
"Hide unavailable conversions by default",
)}
</span>
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableConversions")}
/>
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableConversions.description",
"Remove disabled conversion options instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableConversions || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({
...uiSettings,
defaultHideUnavailableConversions: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving || uiSaving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,376 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Badge,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeaturesSettingsData {
serverCertificate?: {
enabled?: boolean;
organizationName?: string;
validity?: number;
regenerateOnStartup?: boolean;
};
}
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<FeaturesSettingsData>({
sectionName: "features",
fetchTransformer: async (): Promise<
FeaturesSettingsData & { _pending?: Record<string, unknown> }
> => {
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: FeaturesSettingsData & {
_pending?: Record<string, unknown>;
} = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: "Stirling-PDF",
validity: 365,
regenerateOnStartup: false,
},
};
// Map pending changes from system._pending.serverCertificate
if (systemData._pending?.serverCertificate) {
result._pending = {
serverCertificate: systemData._pending.serverCertificate,
};
}
return result;
},
saveTransformer: (settings: FeaturesSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
if (settings.serverCertificate) {
deltaSettings["system.serverCertificate.enabled"] =
settings.serverCertificate.enabled;
deltaSettings["system.serverCertificate.organizationName"] =
settings.serverCertificate.organizationName;
deltaSettings["system.serverCertificate.validity"] =
settings.serverCertificate.validity;
deltaSettings["system.serverCertificate.regenerateOnStartup"] =
settings.serverCertificate.regenerateOnStartup;
}
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.features.title", "Features")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.features.description",
"Configure optional features and functionality.",
)}
</Text>
</div>
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.features.serverCertificate.label",
"Server Certificate",
)}
</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.features.serverCertificate.description",
'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality',
)}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.enabled.label",
"Enable Server Certificate",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.enabled.description",
'Enable server-side certificate for "Sign with Stirling-PDF" option',
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
enabled: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge
show={isFieldPending("serverCertificate.enabled")}
/>
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.features.serverCertificate.organizationName.label",
"Organization Name",
)}
</span>
<PendingBadge
show={isFieldPending(
"serverCertificate.organizationName",
)}
/>
</Group>
}
description={t(
"admin.settings.features.serverCertificate.organizationName.description",
"Organization name for generated certificates",
)}
value={
settings.serverCertificate?.organizationName || "Stirling-PDF"
}
onChange={(e) =>
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
organizationName: e.target.value,
},
})
}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.features.serverCertificate.validity.label",
"Certificate Validity (days)",
)}
</span>
<PendingBadge
show={isFieldPending("serverCertificate.validity")}
/>
</Group>
}
description={t(
"admin.settings.features.serverCertificate.validity.description",
"Number of days the certificate will be valid",
)}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) =>
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
validity: Number(value),
},
})
}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
"Regenerate on Startup",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.description",
"Generate new certificate on each application startup",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={
settings.serverCertificate?.regenerateOnStartup ?? false
}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
regenerateOnStartup: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge
show={isFieldPending("serverCertificate.regenerateOnStartup")}
/>
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,285 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import WarningIcon from "@mui/icons-material/Warning";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: "legal",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.legal.title", "Legal Documents")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.legal.description",
"Configure links to legal documents and policies.",
)}
</Text>
</div>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t(
"admin.settings.legal.disclaimer.title",
"Legal Responsibility Warning",
)}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
"admin.settings.legal.disclaimer.message",
"By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.",
)}
</Text>
</Alert>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.termsAndConditions.label",
"Terms and Conditions",
)}
</span>
<PendingBadge show={isFieldPending("termsAndConditions")} />
</Group>
}
description={t(
"admin.settings.legal.termsAndConditions.description",
"URL or filename to terms and conditions",
)}
value={settings.termsAndConditions || ""}
onChange={(e) =>
setSettings({
...settings,
termsAndConditions: e.target.value,
})
}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.privacyPolicy.label",
"Privacy Policy",
)}
</span>
<PendingBadge show={isFieldPending("privacyPolicy")} />
</Group>
}
description={t(
"admin.settings.legal.privacyPolicy.description",
"URL or filename to privacy policy",
)}
value={settings.privacyPolicy || ""}
onChange={(e) =>
setSettings({ ...settings, privacyPolicy: e.target.value })
}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.accessibilityStatement.label",
"Accessibility Statement",
)}
</span>
<PendingBadge
show={isFieldPending("accessibilityStatement")}
/>
</Group>
}
description={t(
"admin.settings.legal.accessibilityStatement.description",
"URL or filename to accessibility statement",
)}
value={settings.accessibilityStatement || ""}
onChange={(e) =>
setSettings({
...settings,
accessibilityStatement: e.target.value,
})
}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.cookiePolicy.label",
"Cookie Policy",
)}
</span>
<PendingBadge show={isFieldPending("cookiePolicy")} />
</Group>
}
description={t(
"admin.settings.legal.cookiePolicy.description",
"URL or filename to cookie policy",
)}
value={settings.cookiePolicy || ""}
onChange={(e) =>
setSettings({ ...settings, cookiePolicy: e.target.value })
}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.legal.impressum.label", "Impressum")}
</span>
<PendingBadge show={isFieldPending("impressum")} />
</Group>
}
description={t(
"admin.settings.legal.impressum.description",
"URL or filename to impressum (required in some jurisdictions)",
)}
value={settings.impressum || ""}
onChange={(e) =>
setSettings({ ...settings, impressum: e.target.value })
}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,334 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Anchor,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface MailSettingsData {
enabled?: boolean;
enableInvites?: boolean;
inviteLinkExpiryHours?: number;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
type MailApiResponse = MailSettingsData &
ApiResponseWithPending<MailSettingsData>;
export default function AdminMailSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: "mail",
fetchTransformer: async (): Promise<
MailSettingsData & { _pending?: Partial<MailSettingsData> }
> => {
const mailResponse = await apiClient.get<MailApiResponse>(
"/api/v1/admin/settings/section/mail",
);
return mailResponse.data || {};
},
saveTransformer: (settings: MailSettingsData) => {
return {
sectionData: settings,
deltaSettings: {},
};
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.mail.title", "Mail Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.mail.description",
"Configure SMTP settings for email notifications.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mail.enabled.label", "Enable Mail")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mail.enabled.description",
"Enable email notifications and SMTP functionality",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.mail.enableInvites.label",
"Enable Email Invites",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mail.enableInvites.description",
"Allow admins to invite users via email with auto-generated passwords",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t(
"admin.settings.mail.frontendUrlNote.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.mail.frontendUrlNote.link",
"Configure in System Settings",
)}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) =>
setSettings({
...settings,
enableInvites: e.target.checked,
})
}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending("enableInvites")} />
</Group>
</Group>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.host.label", "SMTP Host")}
</span>
<PendingBadge show={isFieldPending("host")} />
</Group>
}
description={t(
"admin.settings.mail.host.description",
"SMTP server hostname",
)}
value={settings.host || ""}
onChange={(e) =>
setSettings({ ...settings, host: e.target.value })
}
placeholder="smtp.example.com"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.port.label", "SMTP Port")}
</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.mail.port.description",
"SMTP server port (typically 587 for TLS, 465 for SSL)",
)}
value={settings.port || 587}
onChange={(value) =>
setSettings({ ...settings, port: Number(value) })
}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.username.label", "SMTP Username")}
</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t(
"admin.settings.mail.username.description",
"SMTP authentication username",
)}
value={settings.username || ""}
onChange={(e) =>
setSettings({ ...settings, username: e.target.value })
}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
{t("admin.settings.mail.password.label", "SMTP Password")}
</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t(
"admin.settings.mail.password.description",
"SMTP authentication password",
)}
value={settings.password || ""}
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter SMTP password"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.from.label", "From Address")}
</span>
<PendingBadge show={isFieldPending("from")} />
</Group>
}
description={t(
"admin.settings.mail.from.description",
"Email address to use as sender",
)}
value={settings.from || ""}
onChange={(e) =>
setSettings({ ...settings, from: e.target.value })
}
placeholder="[email protected]"
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,241 @@
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Divider, Loader, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePlans } from "@app/hooks/usePlans";
import licenseService, {
PlanTierGroup,
mapLicenseToTier,
} from "@app/services/licenseService";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { useLicense } from "@app/contexts/LicenseContext";
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { alert } from "@app/components/toast";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
getPreferredCurrency,
setCachedCurrency,
} from "@app/utils/currencyDetection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@core/components/shared/config/LoginRequiredBanner";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { openCheckout } = useCheckout();
const { licenseInfo } = useLicense();
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
// Check if we should use static version
useEffect(() => {
// Only use static version if Supabase is not configured or there's an error
// Stripe key is not required - hosted checkout works without it
if (!isSupabaseConfigured || error) {
setUseStaticVersion(true);
}
}, [error]);
const currencyOptions = [
{ value: "gbp", label: "British pound (GBP, £)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
];
const handleManageClick = useCallback(async () => {
// Block access if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
// Only allow PRO or ENTERPRISE licenses to access billing portal
if (!licenseInfo?.licenseType || licenseInfo.licenseType === "NORMAL") {
throw new Error(
"No valid license found. Please purchase a license before accessing the billing portal.",
);
}
if (!licenseInfo?.licenseKey) {
throw new Error("License key missing. Please contact support.");
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
} catch (error: unknown) {
console.error("Failed to open billing portal:", error);
alert({
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
}
}, [licenseInfo, t, validateLoginEnabled]);
const handleCurrencyChange = useCallback((newCurrency: string) => {
setCurrency(newCurrency);
// Persist user's manual selection to localStorage
setCachedCurrency(newCurrency);
}, []);
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Block access if login is disabled
if (!validateLoginEnabled()) {
return;
}
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === "free") {
return;
}
// Prevent free tier users from directly accessing enterprise (must have server first)
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === "free" && planGroup.tier === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
onSuccess: () => {
// Refetch plans after successful payment
// License context will auto-update
refetch();
},
});
},
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled],
);
const shouldShowLicenseWarning =
licenseAlert.active && licenseAlert.audience === "admin";
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
limit: licenseAlert.freeTierLimit,
});
}
return licenseAlert.totalUsers.toLocaleString();
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById("available-plans-section");
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
// Early returns after all hooks are called
if (loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
}
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t(
"plan.licenseWarning.title",
"Free self-hosted limit reached",
)}
message={t("plan.licenseWarning.body", {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
})}
buttonText={t("plan.licenseWarning.cta", "See plans")}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
onManageClick={handleManageClick}
currency={currency}
onCurrencyChange={handleCurrencyChange}
currencyOptions={currencyOptions}
loginEnabled={loginEnabled}
/>
<Divider />
{/* License Key Section */}
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};
export default AdminPlanSection;
@@ -0,0 +1,235 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
List,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: "premium",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.premium.title", "Premium & Enterprise")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.premium.description",
"Configure your premium or enterprise license key.",
)}
</Text>
</div>
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t(
"admin.settings.premium.movedFeatures.title",
"Premium Features Distributed",
)}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.movedFeatures.message",
"Premium and Enterprise features are now organized in their respective sections:",
)}
</Text>
<List mt="xs" size="sm">
<List.Item>
<Text size="sm" component="span">
<strong>SSO Auto Login</strong> (PRO) - Connections
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Custom Metadata</strong> (PRO) - General
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Audit Logging</strong> (ENTERPRISE) - Security
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Database Configuration</strong> (ENTERPRISE) - Database
</Text>
</List.Item>
</List>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.premium.license", "License Configuration")}
</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.premium.key.label", "License Key")}
</span>
<PendingBadge show={isFieldPending("key")} />
</Group>
}
description={t(
"admin.settings.premium.key.description",
"Enter your premium or enterprise license key",
)}
value={settings.key || ""}
onChange={(e) =>
setSettings({ ...settings, key: e.target.value })
}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.premium.enabled.label",
"Enable Premium Features",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.premium.enabled.description",
"Enable license key checks for pro/enterprise features",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,306 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Switch, Stack, Paper, Text, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import apiClient from "@app/services/apiClient";
interface PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PrivacySettingsData>({
sectionName: "privacy",
fetchTransformer: async (): Promise<
PrivacySettingsData & { _pending?: Record<string, unknown> }
> => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get("/api/v1/admin/settings/section/metrics"),
apiClient.get("/api/v1/admin/settings/section/system"),
]);
const metrics = metricsResponse.data;
const system = systemResponse.data;
const result: PrivacySettingsData & {
_pending?: Record<string, unknown>;
} = {
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false,
};
// Merge pending blocks from both endpoints
const pendingBlock: Record<string, unknown> = {};
if (system._pending?.enableAnalytics !== undefined) {
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
}
if (system._pending?.googlevisibility !== undefined) {
pendingBlock.googleVisibility = system._pending.googlevisibility;
}
if (metrics._pending?.enabled !== undefined) {
pendingBlock.metricsEnabled = metrics._pending.enabled;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings: PrivacySettingsData) => {
const deltaSettings = {
"system.enableAnalytics": settings.enableAnalytics,
"system.googlevisibility": settings.googleVisibility,
"metrics.enabled": settings.metricsEnabled,
};
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.privacy.title", "Privacy")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.privacy.description",
"Configure privacy and data collection settings.",
)}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.privacy.analytics", "Analytics & Tracking")}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.enableAnalytics.label",
"Enable Analytics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.enableAnalytics.description",
"Collect anonymous usage analytics to help improve the application",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
enableAnalytics: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableAnalytics")} />
</Group>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.metricsEnabled.label",
"Enable Metrics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.metricsEnabled.description",
"Enable collection of performance and usage metrics",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
metricsEnabled: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("metricsEnabled")} />
</Group>
</div>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t(
"admin.settings.privacy.searchEngine",
"Search Engine Visibility",
)}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.googleVisibility.label",
"Google Visibility",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.googleVisibility.description",
"Allow search engines to index this application",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
googleVisibility: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("googleVisibility")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,415 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Anchor,
Badge,
Group,
Loader,
Paper,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useNavigate } from "react-router-dom";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
interface StorageSharingSettingsData {
enabled?: boolean;
sharing?: {
enabled?: boolean;
linkEnabled?: boolean;
emailEnabled?: boolean;
};
signing?: {
enabled?: boolean;
};
system?: {
frontendUrl?: string;
};
mail?: {
enabled?: boolean;
};
}
export default function AdminStorageSharingSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<StorageSharingSettingsData>({
sectionName: "storage",
fetchTransformer: async () => {
const [storageResponse, systemResponse, mailResponse] = await Promise.all(
[
apiClient.get("/api/v1/admin/settings/section/storage"),
apiClient.get("/api/v1/admin/settings/section/system"),
apiClient.get("/api/v1/admin/settings/section/mail"),
],
);
const storageData = storageResponse.data || {};
const systemData = systemResponse.data || {};
const mailData = mailResponse.data || {};
return {
...storageData,
system: { frontendUrl: systemData.frontendUrl || "" },
mail: { enabled: mailData.enabled || false },
};
},
saveTransformer: (currentSettings) => ({
sectionData: {
enabled: currentSettings.enabled,
sharing: {
enabled: currentSettings.sharing?.enabled,
linkEnabled: currentSettings.sharing?.linkEnabled,
emailEnabled: currentSettings.sharing?.emailEnabled,
},
signing: {
enabled: currentSettings.signing?.enabled,
},
},
}),
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const storageEnabled = settings.enabled ?? false;
const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false);
const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());
const mailEnabled = Boolean(settings.mail?.enabled);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
if (loginEnabled && loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<div className="settings-section-content">
<Stack gap="sm">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{t("admin.settings.storage.title", "File Storage & Sharing")}
</Text>
<Badge size="sm" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"admin.settings.storage.description",
"Control server storage and sharing options.",
)}
</Text>
</div>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.enabled.label",
"Enable Server File Storage",
)}
</Text>
{isFieldPending("enabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.enabled.description",
"Allow users to store files on the server.",
)}
</Text>
</div>
<Switch
checked={storageEnabled}
onChange={(e) =>
setSettings({ ...settings, enabled: e.currentTarget.checked })
}
disabled={!loginEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.enabled.label",
"Enable Sharing",
)}
</Text>
{isFieldPending("sharing.enabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.enabled.description",
"Allow users to share stored files.",
)}
</Text>
</div>
<Switch
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.links.label",
"Enable Share Links",
)}
</Text>
{isFieldPending("sharing.linkEnabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.links.description",
"Allow sharing via signed-in links.",
)}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
{t(
"admin.settings.storage.sharing.links.frontendUrlNote",
"Requires a Frontend URL. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.storage.sharing.links.frontendUrlLink",
"Configure in System Settings",
)}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
linkEnabled: e.currentTarget.checked,
},
})
}
disabled={
!loginEnabled || !sharingEnabled || !frontendUrlConfigured
}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.email.label",
"Enable Email Sharing",
)}
</Text>
{isFieldPending("sharing.emailEnabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.email.description",
"Allow sharing with email addresses.",
)}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
{t(
"admin.settings.storage.sharing.email.mailNote",
"Requires mail configuration. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminConnections");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.storage.sharing.email.mailLink",
"Configure Mail Settings",
)}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
emailEnabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.signing.enabled.label",
"Enable Group Signing",
)}
</Text>
{isFieldPending("signing.enabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.signing.enabled.description",
"Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.",
)}
</Text>
</div>
<Switch
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
signing: {
...settings.signing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Stack,
Group,
Text,
Button,
SegmentedControl,
Loader,
Alert,
Card,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import usageAnalyticsService, {
EndpointStatisticsResponse,
} from "@app/services/usageAnalyticsService";
import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart";
import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [displayMode, setDisplayMode] = useState<"top10" | "top20" | "all">(
"top10",
);
const [dataType, setDataType] = useState<"all" | "api" | "ui">("api");
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
const totalVisits = 15847;
const allEndpoints = [
{
endpoint: "merge-pdfs",
visits: 3245,
percentage: (3245 / totalVisits) * 100,
},
{
endpoint: "compress-pdf",
visits: 2891,
percentage: (2891 / totalVisits) * 100,
},
{
endpoint: "pdf-to-img",
visits: 2156,
percentage: (2156 / totalVisits) * 100,
},
{
endpoint: "split-pdf",
visits: 1834,
percentage: (1834 / totalVisits) * 100,
},
{
endpoint: "rotate-pdf",
visits: 1523,
percentage: (1523 / totalVisits) * 100,
},
{
endpoint: "ocr-pdf",
visits: 1287,
percentage: (1287 / totalVisits) * 100,
},
{
endpoint: "add-watermark",
visits: 945,
percentage: (945 / totalVisits) * 100,
},
{
endpoint: "extract-images",
visits: 782,
percentage: (782 / totalVisits) * 100,
},
{
endpoint: "add-password",
visits: 621,
percentage: (621 / totalVisits) * 100,
},
{
endpoint: "html-to-pdf",
visits: 563,
percentage: (563 / totalVisits) * 100,
},
{
endpoint: "remove-password",
visits: 487,
percentage: (487 / totalVisits) * 100,
},
{
endpoint: "pdf-to-pdfa",
visits: 423,
percentage: (423 / totalVisits) * 100,
},
{
endpoint: "extract-pdf-metadata",
visits: 356,
percentage: (356 / totalVisits) * 100,
},
{
endpoint: "add-page-numbers",
visits: 298,
percentage: (298 / totalVisits) * 100,
},
{ endpoint: "crop", visits: 245, percentage: (245 / totalVisits) * 100 },
{
endpoint: "flatten",
visits: 187,
percentage: (187 / totalVisits) * 100,
},
{
endpoint: "sanitize-pdf",
visits: 134,
percentage: (134 / totalVisits) * 100,
},
{
endpoint: "auto-split-pdf",
visits: 98,
percentage: (98 / totalVisits) * 100,
},
{
endpoint: "scale-pages",
visits: 76,
percentage: (76 / totalVisits) * 100,
},
{
endpoint: "compare-pdfs",
visits: 42,
percentage: (42 / totalVisits) * 100,
},
];
let filteredEndpoints = allEndpoints;
if (displayMode === "top10") {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === "top20") {
filteredEndpoints = allEndpoints.slice(0, 20);
}
return {
totalVisits,
totalEndpoints: filteredEndpoints.length,
endpoints: filteredEndpoints,
};
}, [displayMode]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const limit =
displayMode === "all" ? undefined : displayMode === "top10" ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(
limit,
dataType,
);
setData(response);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load usage statistics",
);
} finally {
setLoading(false);
}
}, [dataType, displayMode]);
useEffect(() => {
if (!showDemoData) {
fetchData();
return;
}
// Provide example usage analytics data when running in demo mode
setError(null);
setData(buildDemoUsageData());
setLoading(false);
}, [buildDemoUsageData, fetchData, showDemoData]);
const handleRefresh = () => {
if (showDemoData) {
setData(buildDemoUsageData());
return;
}
fetchData();
};
const getDisplayModeLabel = () => {
switch (displayMode) {
case "top10":
return t("usage.showing.top10", "Top 10");
case "top20":
return t("usage.showing.top20", "Top 20");
case "all":
return t("usage.showing.all", "All");
default:
return "";
}
};
// Override loading state when showing demo data
const actualLoading = showDemoData ? false : loading;
// Early returns for loading/error states
if (actualLoading) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "2rem" }}
>
<Loader size="lg" />
</div>
);
}
if (error) {
return (
<Alert
color="red"
title={t("usage.error", "Error loading usage statistics")}
>
{error}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title={t("usage.noData", "No data available")}>
{t(
"usage.noDataMessage",
"No usage statistics are currently available.",
)}
</Alert>
);
}
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
endpoint:
endpoint.endpoint ?? t("usage.table.unknownEndpoint", "Unknown endpoint"),
visits: Number.isFinite(endpoint.visits) ? Math.max(0, endpoint.visits) : 0,
percentage: Number.isFinite(endpoint.percentage)
? Math.max(0, endpoint.percentage)
: 0,
}));
const chartData = endpoints.map((e) => ({
label: e.endpoint,
value: Number.isFinite(e.visits) ? Math.max(0, e.visits) : 0,
}));
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
const totalVisits = Number.isFinite(data?.totalVisits)
? Math.max(0, data?.totalVisits ?? 0)
: displayedVisits;
const totalEndpoints = Number.isFinite(data?.totalEndpoints)
? Math.max(0, data?.totalEndpoints ?? 0)
: endpoints.length;
const displayedPercentage =
totalVisits > 0
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: "0";
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
)}
/>
{/* Info banner about usage analytics and audit relationship */}
{loginEnabled && hasEnterpriseLicense && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t("usage.aboutUsageAnalytics", "About Usage Analytics")}
color="cyan"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
"usage.usageAnalyticsExplanation",
"Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.",
)}
</Text>
<Group gap="sm">
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.configureSettings", "Configure Analytics Settings")}
</Button>
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.viewAuditLogs", "View Audit Logs")}
</Button>
</Group>
</Stack>
</Alert>
)}
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group>
<SegmentedControl
value={displayMode}
onChange={(value) =>
setDisplayMode(value as "top10" | "top20" | "all")
}
disabled={showDemoData}
data={[
{
value: "top10",
label: t("usage.controls.top10", "Top 10"),
},
{
value: "top20",
label: t("usage.controls.top20", "Top 20"),
},
{
value: "all",
label: t("usage.controls.all", "All"),
},
]}
/>
<Button
variant="outline"
leftSection={
<LocalIcon icon="refresh" width="1rem" height="1rem" />
}
onClick={handleRefresh}
loading={loading}
disabled={showDemoData}
>
{t("usage.controls.refresh", "Refresh")}
</Button>
</Group>
</Group>
<Group>
<Text size="sm" fw={500}>
{t("usage.controls.dataTypeLabel", "Data Type:")}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as "all" | "api" | "ui")}
disabled={showDemoData}
data={[
{
value: "all",
label: t("usage.controls.dataType.all", "All"),
},
{
value: "api",
label: t("usage.controls.dataType.api", "API"),
},
{
value: "ui",
label: t("usage.controls.dataType.ui", "UI"),
},
]}
/>
</Group>
{/* Statistics Summary */}
<Group gap="xl" style={{ flexWrap: "wrap" }}>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.totalEndpoints", "Total Endpoints")}
</Text>
<Text size="lg" fw={600}>
{totalEndpoints}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.totalVisits", "Total Visits")}
</Text>
<Text size="lg" fw={600}>
{totalVisits.toLocaleString()}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.showing", "Showing")}
</Text>
<Text size="lg" fw={600}>
{getDisplayModeLabel()}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.selectedVisits", "Selected Visits")}
</Text>
<Text size="lg" fw={600}>
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
</Text>
</div>
</Group>
</Stack>
</Card>
{/* Chart and Table */}
<UsageAnalyticsChart data={chartData} />
<UsageAnalyticsTable data={endpoints} />
</Stack>
);
};
export default AdminUsageSection;
@@ -0,0 +1,197 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core";
// eslint-disable-next-line no-restricted-imports
import ApiKeySection from "./apiKeys/ApiKeySection";
// eslint-disable-next-line no-restricted-imports
import RefreshModal from "./apiKeys/RefreshModal";
// eslint-disable-next-line no-restricted-imports
import useApiKey from "./apiKeys/hooks/useApiKey";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
export default function ApiKeys() {
const [copied, setCopied] = useState<string | null>(null);
const [showRefreshModal, setShowRefreshModal] = useState(false);
const { t } = useTranslation();
const {
apiKey,
isLoading: apiKeyLoading,
refresh,
isRefreshing,
error: apiKeyError,
refetch,
} = useApiKey();
const copy = async (text: string, tag: string) => {
try {
// Try modern Clipboard API first (requires HTTPS)
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
} else {
// Fallback for HTTP: use old execCommand method
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
if (document.execCommand("copy")) {
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
}
document.body.removeChild(textarea);
}
} catch (e) {
console.error("Failed to copy:", e);
}
};
const refreshKeys = async () => {
try {
await refresh();
} finally {
setShowRefreshModal(false);
}
};
return (
<Stack gap={20} p={0}>
<Text size="sm" c="dimmed">
{t(
"config.apiKeys.intro",
"Use your API key to programmatically access Stirling PDF's processing capabilities.",
)}
</Text>
<Paper
p="md"
radius="md"
style={{
background: "var(--bg-muted)",
border: "1px solid var(--border-subtle)",
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<LocalIcon
icon="info-rounded"
width={18}
height={18}
style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }}
/>
<Stack gap={8} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("config.apiKeys.docsTitle", "API Documentation")}
</Text>
<Text size="sm" c="dimmed">
{t(
"config.apiKeys.docsDescription",
"Learn more about integrating with Stirling PDF:",
)}
</Text>
<Stack gap={4}>
<Text size="sm">
<Anchor
href="https://docs.stirlingpdf.com/API"
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.docsLink", "API Documentation")}
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
<Text size="sm">
<Anchor
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.schemaLink", "API Schema Reference")}
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
</Stack>
</Stack>
</Group>
</Paper>
{apiKeyError && (
<Text size="sm" c="red.5">
{t(
"config.apiKeys.generateError",
"We couldn't generate your API key.",
)}{" "}
<Anchor
component="button"
underline="always"
onClick={refetch}
c="red.4"
>
{t("common.retry", "Retry")}
</Anchor>
</Text>
)}
{apiKeyLoading ? (
<div
style={{
padding: 18,
borderRadius: 12,
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="center" gap={12} wrap="nowrap">
<Skeleton height={36} style={{ flex: 1 }} />
<Skeleton height={32} width={76} />
<Skeleton height={32} width={92} />
</Group>
</div>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)}
<Text size="sm" c="dimmed" style={{ marginTop: -8 }}>
{t(
"config.apiKeys.usage",
"Include this key in the X-API-KEY header with all API requests.",
)}
</Text>
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}
@@ -0,0 +1,838 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Button,
Table,
ActionIcon,
Badge,
Loader,
Group,
Modal,
Select,
CloseButton,
Tooltip,
Menu,
Avatar,
Box,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import {
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
interface TeamDetailsSectionProps {
teamId: number;
onBack: () => void;
}
export default function TeamDetailsSection({
teamId,
onBack,
}: TeamDetailsSectionProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState<Team | null>(null);
const [teamUsers, setTeamUsers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [userLastRequest, setUserLastRequest] = useState<
Record<string, number>
>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] =
useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
availableSlots: number;
} | null>(null);
const [mailEnabled, setMailEnabled] = useState(false);
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
useEffect(() => {
fetchTeamDetails();
fetchAllTeams();
}, [teamId]);
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([
teamService.getTeamDetails(teamId),
userManagementService.getUsers(),
]);
console.log("[TeamDetailsSection] Raw data:", data);
setTeam(data.team);
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
setAvailableUsers(
Array.isArray(data.availableUsers) ? data.availableUsers : [],
);
setUserLastRequest(data.userLastRequest || {});
// Store license information
setLicenseInfo({
availableSlots: adminData.availableSlots,
});
setMailEnabled(adminData.mailEnabled);
setLockedUsers(adminData.lockedUsers || []);
} catch (error) {
console.error("Failed to fetch team details:", error);
alert({
alertType: "error",
title: t("workspace.teams.loadError", "Failed to load team details"),
});
onBack();
} finally {
setLoading(false);
}
};
const fetchAllTeams = async () => {
try {
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error("Failed to fetch teams:", error);
}
};
const handleAddMember = async () => {
if (!selectedUserId) {
alert({
alertType: "error",
title: t(
"workspace.teams.addMemberToTeam.selectUserRequired",
"Please select a user",
),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({
alertType: "success",
title: t(
"workspace.teams.addMemberToTeam.success",
"User added to team successfully",
),
});
setAddMemberModalOpened(false);
setSelectedUserId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to add member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.addMemberToTeam.error",
"Failed to add user to team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRemoveMember = async (user: User) => {
if (
!window.confirm(
t(
"workspace.teams.confirmRemove",
`Remove ${user.username} from this team?`,
),
)
) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find((t) => t.name === "Default");
if (!defaultTeam) {
throw new Error("Default team not found");
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
defaultTeam.id,
);
alert({
alertType: "success",
title: t(
"workspace.teams.removeMemberSuccess",
"User removed from team",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to remove member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.removeMemberError",
"Failed to remove user from team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.unlockUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("[TeamDetailsSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
};
const openChangeTeamModal = (user: User) => {
setSelectedUser(user);
setSelectedTeamId(user.team?.id?.toString() || "");
setChangeTeamModalOpened(true);
};
const openChangePasswordModal = (user: User) => {
setPasswordUser(user);
setChangePasswordModalOpened(true);
};
const closeChangePasswordModal = () => {
setChangePasswordModalOpened(false);
setPasswordUser(null);
};
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({
alertType: "error",
title: t(
"workspace.teams.changeTeam.selectTeamRequired",
"Please select a team",
),
});
return;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(
selectedUser.username,
selectedUser.rolesAsString || "ROLE_USER",
parseInt(selectedTeamId),
);
alert({
alertType: "success",
title: t(
"workspace.teams.changeTeam.success",
"Team changed successfully",
),
});
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to change team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.changeTeam.error", "Failed to change team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
if (loading) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t("workspace.teams.loadingDetails", "Loading team details...")}
</Text>
</Stack>
);
}
if (!team) {
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t("workspace.teams.teamNotFound", "Team not found")}
</Text>
<Button variant="light" onClick={onBack}>
{t("workspace.teams.backToTeams", "Back to Teams")}
</Button>
</Stack>
);
}
return (
<Stack gap="lg">
{/* Header with back button */}
<Group>
<ActionIcon variant="subtle" onClick={onBack}>
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text fw={600} size="lg">
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t("workspace.teams.memberCount", { count: teamUsers.length })}{" "}
{teamUsers.length === 1 ? "member" : "members"}
</Text>
</div>
</Group>
{/* Add Member Button */}
<Group justify="flex-end">
<Tooltip
label={t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Button
leftSection={
<LocalIcon icon="person-add" width="1rem" height="1rem" />
}
onClick={() => setAddMemberModalOpened(true)}
disabled={
team.name === "Internal" ||
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
}
>
{t("workspace.teams.addMember")}
</Button>
</Tooltip>
</Group>
{/* Members Table */}
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
>
{t("workspace.people.user")}
</Table.Th>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
w={100}
>
{t("workspace.people.role")}
</Table.Th>
<Table.Th w={50}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noMembers", "No members in this team")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teamUsers.map((user) => {
const isActive =
userLastRequest[user.username] &&
Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
return (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t("workspace.people.disabled", "Disabled")
: isActive
? t(
"workspace.people.activeSession",
"Active session",
)
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: isActive
? "2px solid var(--mantine-color-green-6)"
: "none",
opacity: user.enabled ? 1 : 0.5,
},
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Tooltip
label={user.username}
disabled={user.username.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
maw={200}
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
</Text>
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t("workspace.people.lockedBadge", "Locked")}
</Badge>
)}
</Group>
{user.email && (
<Text
size="xs"
c="dimmed"
truncate
style={{ lineHeight: 1.3 }}
>
{user.email}
</Text>
)}
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
color={
(user.rolesAsString || "").includes("ROLE_ADMIN")
? "blue"
: "cyan"
}
variant="light"
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin")
: t("workspace.people.member")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication:{" "}
{user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{userLastRequest[user.username]
? new Date(
userLastRequest[user.username],
).toLocaleString()
: "Never"}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={
<LocalIcon
icon="swap-horiz"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === "Internal"}
>
{t(
"workspace.teams.changeTeam.label",
"Change Team",
)}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon
icon="lock"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangePasswordModal(user)}
disabled={processing}
>
{t(
"workspace.people.changePassword.action",
"Change password",
)}
</Menu.Item>
{isLockedUser(user) && (
<Menu.Item
leftSection={
<LocalIcon
icon="lock-open"
width="1rem"
height="1rem"
/>
}
onClick={() => handleUnlockUser(user)}
disabled={processing}
>
{t(
"workspace.people.unlockAccount",
"Unlock Account",
)}
</Menu.Item>
)}
{team.name !== "Internal" &&
team.name !== "Default" && (
<Menu.Item
leftSection={
<LocalIcon
icon="person-remove"
width="1rem"
height="1rem"
/>
}
onClick={() => handleRemoveMember(user)}
disabled={processing}
>
{t(
"workspace.teams.removeMember",
"Remove from team",
)}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={
<LocalIcon
icon="delete"
width="1rem"
height="1rem"
/>
}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === "Internal"}
>
{t("workspace.people.deleteUser", "Delete User")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchTeamDetails}
mailEnabled={mailEnabled}
/>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Change Team Modal */}
<Modal
opened={changeTeamModalOpened}
onClose={() => setChangeTeamModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="swap-horiz"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.changeTeam.title", "Change Team")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.changeTeam.changing", "Moving")}{" "}
<strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
placeholder={t(
"workspace.teams.changeTeam.selectTeamPlaceholder",
"Choose a team",
)}
data={allTeams
.filter((t) => t.name !== "Internal")
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Button
onClick={handleChangeTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.changeTeam.submit", "Change Team")}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,642 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Select,
CloseButton,
Tooltip,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState("");
const [renameTeamName, setRenameTeamName] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
try {
setLoading(true);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
{ id: 3, name: "Internal", userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error("Failed to fetch teams:", error);
alert({ alertType: "error", title: "Failed to load teams" });
} finally {
setLoading(false);
}
};
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({
alertType: "error",
title: t("workspace.teams.createTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({
alertType: "success",
title: t("workspace.teams.createTeam.success"),
});
setNewTeamName("");
setCreateModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to create team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.createTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({
alertType: "error",
title: t("workspace.teams.renameTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({
alertType: "success",
title: t("workspace.teams.renameTeam.success"),
});
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to rename team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.renameTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotDeleteInternal"),
});
return;
}
if (!confirm(t("workspace.teams.confirmDelete"))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({
alertType: "success",
title: t("workspace.teams.deleteTeam.success"),
});
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to delete team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.deleteTeam.error");
alert({ alertType: "error", title: errorMessage });
}
};
const openRenameModal = (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotRenameInternal"),
});
return;
}
setSelectedTeam(team);
setRenameTeamName(team.name);
setRenameModalOpened(true);
};
const openAddMemberModal = async (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotAddToInternal"),
});
return;
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error("Failed to fetch users:", error);
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.userRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(
selectedTeam.id,
parseInt(selectedUserId),
);
alert({
alertType: "success",
title: t("workspace.teams.addMemberToTeam.success"),
});
setSelectedUserId("");
setSelectedTeam(null);
setAddMemberModalOpened(false);
await fetchTeams();
} catch (error) {
console.error("Failed to add member to team:", error);
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
} finally {
setProcessing(false);
}
};
// If viewing team details, render TeamDetailsSection
if (viewingTeamId !== null) {
return (
<TeamDetailsSection
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
}
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t("workspace.teams.loading", "Loading teams...")}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("workspace.teams.title")}
</Text>
<Text size="sm" c="dimmed">
{t("workspace.teams.description")}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={() => setCreateModalOpened(true)}
disabled={!loginEnabled}
>
{t("workspace.teams.createNewTeam")}
</Button>
</Group>
{/* Teams Table */}
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("workspace.teams.teamName")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("workspace.teams.totalMembers")}
</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noTeamsFound")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: loginEnabled ? "pointer" : "default" }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Tooltip
label={team.name}
disabled={team.name.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
maw={200}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{team.name}
</Text>
</Tooltip>
{team.name === "Internal" && (
<Badge size="xs" color="gray" variant="light">
{t("workspace.teams.system")}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{team.userCount || 0}
</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
disabled={!loginEnabled}
>
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={
<LocalIcon
icon="visibility"
width="1rem"
height="1rem"
/>
}
onClick={() => setViewingTeamId(team.id)}
disabled={!loginEnabled}
>
{t("workspace.teams.viewTeam", "View Team")}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon icon="group" width="1rem" height="1rem" />
}
onClick={() => openAddMemberModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.addMember")}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon icon="edit" width="1rem" height="1rem" />
}
onClick={() => openRenameModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.renameTeamLabel")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={
<LocalIcon icon="delete" width="1rem" height="1rem" />
}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === "Internal"}
>
{t("workspace.teams.deleteTeamLabel")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Create Team Modal */}
<Modal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="group-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.createTeam.title")}
</Text>
</Stack>
<TextInput
label={t("workspace.teams.createTeam.teamName")}
placeholder={t("workspace.teams.createTeam.teamNamePlaceholder")}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button
onClick={handleCreateTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.createTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Rename Team Modal */}
<Modal
opened={renameModalOpened}
onClose={() => setRenameModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="edit"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.renameTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.renameTeam.renaming")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t("workspace.teams.renameTeam.newTeamName")}
placeholder={t(
"workspace.teams.renameTeam.newTeamNamePlaceholder",
)}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button
onClick={handleRenameTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.renameTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { Box, Button, Group, Paper } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import FitText from "@app/components/shared/FitText";
import { useTranslation } from "react-i18next";
interface ApiKeySectionProps {
publicKey: string;
copied: string | null;
onCopy: (text: string, tag: string) => void;
onRefresh: () => void;
disabled?: boolean;
}
export default function ApiKeySection({
publicKey,
copied,
onCopy,
onRefresh,
disabled,
}: ApiKeySectionProps) {
const { t } = useTranslation();
return (
<>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="flex-end" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Box
style={{
background: "var(--api-keys-input-bg)",
border: "1px solid var(--api-keys-input-border)",
borderRadius: 8,
padding: "8px 12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14,
minHeight: 36,
display: "flex",
alignItems: "center",
}}
aria-label={t(
"config.apiKeys.publicKeyAriaLabel",
"Public API key",
)}
>
<FitText text={publicKey} />
</Box>
</Box>
<Button
size="sm"
variant="light"
onClick={() => onCopy(publicKey, "public")}
leftSection={
<LocalIcon icon="content-copy-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 12,
},
}}
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
>
{copied === "public"
? t("common.copied", "Copied!")
: t("common.copy", "Copy")}
</Button>
<Button
size="sm"
variant="light"
onClick={onRefresh}
leftSection={
<LocalIcon icon="refresh-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 8,
},
}}
disabled={disabled}
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
>
{t("common.refresh", "Refresh")}
</Button>
</Group>
</Paper>
</>
);
}
@@ -0,0 +1,57 @@
import React from "react";
import { Modal, Stack, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface RefreshModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => void;
}
export default function RefreshModal({
opened,
onClose,
onConfirm,
}: RefreshModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onClose}
title={t("config.apiKeys.refreshModal.title", "Refresh API Keys")}
centered
size="sm"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Text size="sm" c="red">
{t(
"config.apiKeys.refreshModal.warning",
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
)}
</Text>
<Text size="sm">
{t(
"config.apiKeys.refreshModal.impact",
"Any applications or services currently using these keys will stop working until you update them with the new keys.",
)}
</Text>
<Text size="sm" fw={500}>
{t(
"config.apiKeys.refreshModal.confirmPrompt",
"Are you sure you want to continue?",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button color="red" onClick={onConfirm}>
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
export function useApiKey() {
const [apiKey, setApiKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const { t } = useTranslation();
function failedToCreateAlert() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToCreateApiKey",
"Failed to create API key.",
),
isPersistentPopup: false,
});
}
const fetchKey = useCallback(async () => {
setIsLoading(true);
setError(null);
// Backend is POST for get and update
await apiClient
.post("/api/v1/user/get-api-key", undefined, {
responseType: "json",
})
.then((response) => {
const data = response.data;
const apiKeyValue = typeof data === "string" ? data : data?.apiKey;
if (typeof apiKeyValue === "string") {
setApiKey(apiKeyValue);
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRetrieveApiKey",
"Failed to retrieve API key from response.",
),
isPersistentPopup: false,
});
}
})
.catch(async (e) => {
// If not found, try to create one by calling update endpoint
if (e?.response?.status === 404) {
await apiClient
.post("/api/v1/user/update-api-key")
.then((createRes) => {
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
if (typeof created === "string") {
setApiKey(created);
} else {
failedToCreateAlert();
}
})
.catch((createErr) => {
failedToCreateAlert();
setError(createErr);
});
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToFetchApiKey",
"Failed to fetch API key.",
),
isPersistentPopup: false,
});
setError(e);
}
})
.finally(() => {
setIsLoading(false);
setHasAttempted(true);
});
}, []);
const refresh = useCallback(async () => {
setIsRefreshing(true);
setError(null);
await apiClient
.post("/api/v1/user/update-api-key", undefined, {
responseType: "json",
suppressErrorToast: true,
})
.then((res) => {
const value =
typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") {
alert({
alertType: "success",
title: t(
"config.apiKeys.alert.apiKeyRefreshed",
"API Key Refreshed",
),
body: t(
"config.apiKeys.alert.apiKeyRefreshedBody",
"Your API key has been successfully refreshed.",
),
isPersistentPopup: false,
});
setApiKey(value);
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
}
})
.catch((e) => {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
setError(e);
})
.finally(() => {
setIsRefreshing(false);
});
}, []);
useEffect(() => {
if (!hasAttempted) {
fetchKey();
}
}, [hasAttempted, fetchKey]);
return {
apiKey,
isLoading,
isRefreshing,
error,
refetch: fetchKey,
refresh,
hasAttempted,
} as const;
}
export default useApiKey;
@@ -0,0 +1,329 @@
import React, { useState, useEffect } from "react";
import {
Card,
Text,
Group,
Stack,
SegmentedControl,
Loader,
Alert,
Box,
SimpleGrid,
} from "@mantine/core";
import {
AreaChart,
Area,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useTranslation } from "react-i18next";
import auditService, { AuditChartsData } from "@app/services/auditService";
// Event type color mapping
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: "var(--mantine-color-green-6)",
USER_LOGOUT: "var(--mantine-color-gray-5)",
USER_FAILED_LOGIN: "var(--mantine-color-red-6)",
USER_PROFILE_UPDATE: "var(--mantine-color-blue-6)",
SETTINGS_CHANGED: "var(--mantine-color-orange-6)",
FILE_OPERATION: "var(--mantine-color-cyan-6)",
PDF_PROCESS: "var(--mantine-color-violet-6)",
UI_DATA: "var(--mantine-color-teal-6)",
HTTP_REQUEST: "var(--mantine-color-indigo-6)",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || "var(--mantine-color-blue-6)";
};
interface AuditChartsSectionProps {
loginEnabled?: boolean;
timePeriod?: "day" | "week" | "month";
onTimePeriodChange?: (period: "day" | "week" | "month") => void;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
loginEnabled = true,
timePeriod = "week",
onTimePeriodChange,
}) => {
const { t } = useTranslation();
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchChartsData = async () => {
try {
setLoading(true);
setError(null);
const data = await auditService.getChartsData(timePeriod);
setChartsData(data);
} catch (err) {
setError(
err instanceof Error
? err.message
: t("audit.charts.error", "Failed to load charts"),
);
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchChartsData();
} else {
// Demo data when login disabled
setChartsData({
eventsByType: {
labels: [
"LOGIN",
"LOGOUT",
"SETTINGS_CHANGE",
"FILE_UPLOAD",
"FILE_DOWNLOAD",
],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ["admin", "user1", "user2", "user3", "user4"],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="center">
<Loader size="lg" my="xl" />
</Group>
</Card>
);
}
if (error) {
return (
<Alert
color="red"
title={t("audit.charts.error", "Error loading charts")}
>
{error}
</Alert>
);
}
if (!chartsData) {
return null;
}
// Transform data for Recharts
const eventsOverTimeData = chartsData.eventsOverTime.labels.map(
(label, index) => ({
name: label,
value: chartsData.eventsOverTime.values[index],
}),
);
const eventsByTypeData = chartsData.eventsByType.labels.map(
(label, index) => ({
type: label,
value: chartsData.eventsByType.values[index],
}),
);
const eventsByUserData = chartsData.eventsByUser.labels.map(
(label, index) => ({
user: label,
value: chartsData.eventsByUser.values[index],
}),
);
return (
<Stack gap="lg">
{/* Header with time period selector */}
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t("audit.charts.title", "Audit Dashboard")}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
onTimePeriodChange?.(value as "day" | "week" | "month");
}}
disabled={!loginEnabled}
data={[
{ label: t("audit.charts.day", "Day"), value: "day" },
{ label: t("audit.charts.week", "Week"), value: "week" },
{ label: t("audit.charts.month", "Month"), value: "month" },
]}
/>
</Group>
{/* Full-width Events Over Time Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.overTime", "Events Over Time")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsOverTimeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={eventsOverTimeData}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--mantine-color-blue-6)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--mantine-color-blue-6)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="var(--mantine-color-blue-6)"
fillOpacity={1}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">
{t("audit.charts.noData", "No data for this period")}
</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Two-column grid for remaining charts */}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
{/* Events by Type Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.byType", "Events by Type")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsByTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByTypeData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis
dataKey="type"
stroke="var(--mantine-color-gray-6)"
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
{eventsByTypeData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getEventTypeColor(entry.type)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Top Users Chart (Horizontal) */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.byUser", "Top Users")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
<YAxis
type="category"
dataKey="user"
stroke="var(--mantine-color-gray-6)"
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
</SimpleGrid>
</Stack>
);
};
export default AuditChartsSection;
@@ -0,0 +1,245 @@
import React, { useState } from "react";
import {
Card,
Stack,
Text,
PasswordInput,
Button,
Group,
Alert,
Code,
Badge,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditClearDataSectionProps {
loginEnabled?: boolean;
}
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({
loginEnabled = true,
}) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState("");
const [generatedCode, setGeneratedCode] = useState("");
const [showConfirmation, setShowConfirmation] = useState(false);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleInitiateDeletion = () => {
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
setGeneratedCode(code);
setConfirmationCode("");
setShowConfirmation(true);
setError(null);
};
const resetForm = () => {
setConfirmationCode("");
setGeneratedCode("");
setShowConfirmation(false);
setError(null);
};
const handleClearData = async () => {
if (confirmationCode !== generatedCode) {
setError(t("audit.clearData.codeDoesNotMatch", "Code does not match"));
return;
}
try {
setClearing(true);
setError(null);
await auditService.clearAllAuditData();
setSuccess(true);
resetForm();
// Auto-dismiss success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to clear audit data",
);
} finally {
setClearing(false);
}
};
if (success) {
return (
<Stack gap="lg">
<Alert
color="green"
icon={
<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />
}
title={t("audit.clearData.success", "Success")}
onClose={() => setSuccess(false)}
closeButtonLabel="Close alert"
withCloseButton
>
{t(
"audit.clearData.successMessage",
"All audit data has been cleared successfully",
)}
</Alert>
</Stack>
);
}
if (showConfirmation) {
return (
<Stack gap="lg">
<Alert
color="orange"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t(
"audit.clearData.confirmTitle",
"Please confirm you want to delete",
)}
>
<Text size="sm">
{t(
"audit.clearData.confirmMessage",
"This will permanently remove all audit logs. Enter the confirmation code below to proceed.",
)}
</Text>
</Alert>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<div
style={{
backgroundColor: "var(--mantine-color-gray-0)",
padding: "1rem",
borderRadius: "0.375rem",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={600} c="dimmed" mb="xs">
{t("audit.clearData.confirmationCode", "Confirmation Code")}
</Text>
<Code
style={{
fontSize: "1.5rem",
letterSpacing: "0.15em",
fontWeight: 600,
display: "block",
textAlign: "center",
padding: "0.75rem",
}}
>
{generatedCode}
</Code>
<Text size="xs" c="dimmed" mt="xs">
{t(
"audit.clearData.enterCodeBelow",
"Enter the code exactly as shown above (case-sensitive)",
)}
</Text>
</div>
<PasswordInput
label={t("audit.clearData.enterCode", "Confirmation Code")}
placeholder={t(
"audit.clearData.codePlaceholder",
"Type the code here",
)}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
disabled={!loginEnabled}
error={
confirmationCode && confirmationCode !== generatedCode
? t("audit.clearData.codeDoesNotMatch", "Code does not match")
: false
}
/>
{error && (
<Alert
color="red"
icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}
>
{error}
</Alert>
)}
<Group justify="space-between">
<Button variant="default" onClick={resetForm} disabled={clearing}>
{t("audit.clearData.cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={handleClearData}
loading={clearing}
disabled={
!loginEnabled ||
!generatedCode ||
confirmationCode !== generatedCode
}
>
{t("audit.clearData.deleteButton", "Delete")}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
return (
<Stack gap="lg">
<Alert
color="red"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t("audit.clearData.warning1", "This action cannot be undone")}
>
<Text size="sm">
{t(
"audit.clearData.warning2",
"Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.",
)}
</Text>
</Alert>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<Group>
<Text fw={600}>
{t(
"audit.clearData.confirmationRequired",
"Delete All Audit Data",
)}
</Text>
<Badge color="red">
{t("audit.clearData.irreversible", "IRREVERSIBLE")}
</Badge>
</Group>
<Button
color="red"
onClick={handleInitiateDeletion}
disabled={!loginEnabled}
fullWidth
>
{t("audit.clearData.initiateDelete", "Delete All Data")}
</Button>
</Stack>
</Card>
</Stack>
);
};
export default AuditClearDataSection;
@@ -0,0 +1,564 @@
import React, { useState, useEffect } from "react";
import {
Card,
Text,
Group,
Stack,
Button,
Pagination,
Modal,
Code,
Loader,
Alert,
Table,
Badge,
UnstyledButton,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, {
AuditEvent,
AuditFilters,
} from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditEventsTableProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
}) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
const [sortKey, setSortKey] = useState<
"timestamp" | "eventType" | "username" | "ipAddress" | null
>("timestamp");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const showAuthor = capturePdfAuthor;
const showFileHash = captureFileHash;
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters(
{
page: 0,
pageSize: 20,
},
loginEnabled,
);
useEffect(() => {
const fetchEvents = async () => {
try {
setLoading(true);
setError(null);
const response = await auditService.getEvents({
...filters,
page: currentPage - 1,
});
setEvents(response.events);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchEvents();
} else {
// Provide example audit events when login is disabled
const now = new Date();
setEvents([
{
id: "1",
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: "LOGIN",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "User logged in successfully" },
},
{
id: "2",
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: "FILE_UPLOAD",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "Uploaded document.pdf" },
},
{
id: "3",
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: "SETTINGS_CHANGE",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "Modified system settings" },
},
{
id: "4",
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: "FILE_DOWNLOAD",
username: "user2",
ipAddress: "192.168.1.102",
details: { message: "Downloaded report.pdf" },
},
{
id: "5",
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: "LOGOUT",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "User logged out" },
},
]);
setTotalPages(1);
setLoading(false);
}
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => {
handleFilterChange(key, value);
setCurrentPage(1);
};
const handleClearFiltersWithReset = () => {
handleClearFilters();
setCurrentPage(1);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Sort handling
const toggleSort = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey === key) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir("asc");
}
};
const getSortIcon = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey !== key) return "unfold-more";
return sortDir === "asc" ? "expand-less" : "expand-more";
};
// Event type colors
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: "green",
USER_LOGOUT: "gray",
USER_FAILED_LOGIN: "red",
USER_PROFILE_UPDATE: "blue",
SETTINGS_CHANGED: "orange",
FILE_OPERATION: "cyan",
PDF_PROCESS: "violet",
UI_DATA: "gray",
HTTP_REQUEST: "indigo",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || "blue";
};
// Apply sorting to current events
const sortedEvents = [...events].sort((a, b) => {
let aVal: string | number | undefined;
let bVal: string | number | undefined;
switch (sortKey) {
case "timestamp":
aVal = new Date(a.timestamp).getTime();
bVal = new Date(b.timestamp).getTime();
break;
case "eventType":
aVal = a.eventType;
bVal = b.eventType;
break;
case "username":
aVal = a.username;
bVal = b.username;
break;
case "ipAddress":
aVal = a.ipAddress;
bVal = b.ipAddress;
break;
default:
return 0;
}
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.events.title", "Audit Events")}
</Text>
{/* Filters */}
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/>
{/* Table */}
{loading ? (
<div style={{ display: "flex", justifyContent: "center" }}>
<Loader size="lg" my="xl" />
</div>
) : error ? (
<Alert
color="red"
title={t("audit.events.error", "Error loading events")}
>
{error}
</Alert>
) : (
<>
<div
style={{
overflowX: "auto",
overflowY: "hidden",
marginBottom: "1rem",
}}
>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("timestamp")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.timestamp", "Timestamp")}
<LocalIcon
icon={getSortIcon("timestamp")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("eventType")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.type", "Type")}
<LocalIcon
icon={getSortIcon("eventType")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("username")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.user", "User")}
<LocalIcon
icon={getSortIcon("username")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.documentName", "Document Name")}
</Table.Th>
{showAuthor && (
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.author", "Author")}
</Table.Th>
)}
{showFileHash && (
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.fileHash", "File Hash")}
</Table.Th>
)}
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
ta="center"
>
{t("audit.events.actions", "Actions")}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={totalColumns}>
<Group justify="center" py="xl">
<Stack align="center" gap={0}>
<LocalIcon
icon="search"
width="2rem"
height="2rem"
style={{ opacity: 0.4 }}
/>
<Text ta="center" c="dimmed" size="sm">
{t("audit.events.noEvents", "No events found")}
</Text>
</Stack>
</Group>
</Table.Td>
</Table.Tr>
) : (
sortedEvents.map((event) => {
// Extract document name, author, hash from details.files if available
let documentName = "";
let author = "";
let fileHash = "";
if (event.details && typeof event.details === "object") {
const details = event.details as Record<
string,
unknown
>;
const files = details.files;
if (Array.isArray(files) && files.length > 0) {
const firstFile = files[0] as Record<string, unknown>;
documentName =
typeof firstFile.name === "string"
? firstFile.name
: "";
if (showAuthor || showFileHash) {
author =
typeof firstFile.pdfAuthor === "string"
? firstFile.pdfAuthor
: "";
fileHash =
typeof firstFile.fileHash === "string"
? firstFile.fileHash.substring(0, 16) + "..."
: "";
}
}
}
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
size="sm"
color={getEventTypeColor(event.eventType)}
>
{event.eventType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" title={documentName}>
{documentName || "—"}
</Text>
</Table.Td>
{showAuthor && (
<Table.Td>
<Text size="sm">{author}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text
size="sm"
title={fileHash}
style={{
fontFamily: "monospace",
fontSize: "0.75rem",
}}
>
{fileHash}
</Text>
</Table.Td>
)}
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t("audit.events.viewDetails", "View Details")}
</Button>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={setCurrentPage}
total={totalPages}
/>
</Group>
)}
</div>
</>
)}
</Stack>
{/* Event Details Modal */}
<Modal
opened={selectedEvent !== null}
onClose={() => setSelectedEvent(null)}
title={t("audit.events.eventDetails", "Event Details")}
size="lg"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{selectedEvent && (
<Stack gap="md">
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.timestamp", "Timestamp")}
</Text>
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.type", "Type")}
</Text>
<Text size="sm">{selectedEvent.eventType}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.user", "User")}
</Text>
<Text size="sm">{selectedEvent.username}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.ipAddress", "IP Address")}
</Text>
<Text size="sm">{selectedEvent.ipAddress}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.details", "Details")}
</Text>
<Code block mah={300} style={{ overflow: "auto" }}>
{JSON.stringify(selectedEvent.details, null, 2)}
</Code>
</div>
</Stack>
)}
</Modal>
</Card>
);
};
export default AuditEventsTable;
@@ -0,0 +1,269 @@
import React, { useState } from "react";
import {
Card,
Text,
Group,
Stack,
Button,
SegmentedControl,
Checkbox,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
interface AuditExportSectionProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
captureOperationResults?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
captureOperationResults = false,
}) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<"csv" | "json">("csv");
const [exporting, setExporting] = useState(false);
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>(
{
date: true,
username: true,
ipaddress: false,
tool: true,
documentName: true,
outcome: true,
author: capturePdfAuthor,
fileHash: captureFileHash,
operationResults: captureOperationResults,
},
);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters({}, loginEnabled);
const handleExport = async () => {
if (!loginEnabled) return;
try {
setExporting(true);
const fieldsParam = Object.keys(selectedFields)
.filter((k) => selectedFields[k])
.join(",");
const blob = await auditService.exportData(exportFormat, {
...filters,
fields: fieldsParam,
});
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
alert(t("audit.export.error", "Failed to export data"));
} finally {
setExporting(false);
}
};
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.export.title", "Export Audit Data")}
</Text>
<Text size="sm" c="dimmed">
{t(
"audit.export.description",
"Export audit events to CSV or JSON format. Use filters to limit the exported data.",
)}
</Text>
{/* Format Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.format", "Export Format")}
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as "csv" | "json");
}}
disabled={!loginEnabled}
data={[
{ label: "CSV", value: "csv" },
{ label: "JSON", value: "json" },
]}
/>
</div>
{/* Field Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.selectFields", "Select Fields to Include")}
</Text>
<Stack gap="xs">
<Checkbox
label={t("audit.export.fieldDate", "Date")}
checked={selectedFields.date}
onChange={(e) =>
setSelectedFields({
...selectedFields,
date: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldUsername", "Username")}
checked={selectedFields.username}
onChange={(e) =>
setSelectedFields({
...selectedFields,
username: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldIpAddress", "IP Address")}
checked={selectedFields.ipaddress}
onChange={(e) =>
setSelectedFields({
...selectedFields,
ipaddress: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldTool", "Tool")}
checked={selectedFields.tool}
onChange={(e) =>
setSelectedFields({
...selectedFields,
tool: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldDocumentName", "Document Name")}
checked={selectedFields.documentName}
onChange={(e) =>
setSelectedFields({
...selectedFields,
documentName: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t(
"audit.export.fieldOutcome",
"Outcome (Success/Failure)",
)}
checked={selectedFields.outcome}
onChange={(e) =>
setSelectedFields({
...selectedFields,
outcome: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
{capturePdfAuthor && (
<Checkbox
label={t("audit.export.fieldAuthor", "Author (from PDF)")}
checked={selectedFields.author}
onChange={(e) =>
setSelectedFields({
...selectedFields,
author: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
{captureFileHash && (
<Checkbox
label={t("audit.export.fieldFileHash", "File Hash (SHA-256)")}
checked={selectedFields.fileHash}
onChange={(e) =>
setSelectedFields({
...selectedFields,
fileHash: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t(
"audit.export.fieldOperationResults",
"Operation Results",
)}
checked={selectedFields.operationResults}
onChange={(e) =>
setSelectedFields({
...selectedFields,
operationResults: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
</Stack>
</div>
{/* Filters */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.filters", "Filters (Optional)")}
</Text>
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
disabled={!loginEnabled}
/>
</div>
{/* Export Button */}
<Group justify="flex-end">
<Button
leftSection={
<LocalIcon icon="download" width="1rem" height="1rem" />
}
onClick={handleExport}
loading={exporting}
disabled={!loginEnabled || exporting}
>
{t("audit.export.exportButton", "Export Data")}
</Button>
</Group>
</Stack>
</Card>
);
};
export default AuditExportSection;
@@ -0,0 +1,231 @@
import React from "react";
import {
Group,
MultiSelect,
Button,
Stack,
SimpleGrid,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { AuditFilters } from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
const formatDateToYMD = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Helper to calculate date range for quick presets
const getDateRange = (preset: string): [Date, Date] | null => {
const end = new Date();
const start = new Date();
switch (preset) {
case "today":
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
case "last7":
start.setDate(start.getDate() - 6);
return [start, end];
case "last30":
start.setDate(start.getDate() - 29);
return [start, end];
case "thisMonth":
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
default:
return null;
}
};
interface AuditFiltersFormProps {
filters: AuditFilters;
eventTypes: string[];
users: string[];
onFilterChange: (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => void;
onClearFilters: () => void;
disabled?: boolean;
}
/**
* Shared filter form for audit components
*/
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
filters,
eventTypes,
users,
onFilterChange,
onClearFilters,
disabled = false,
}) => {
const { t } = useTranslation();
const handleQuickPreset = (preset: string) => {
const range = getDateRange(preset);
if (range) {
const [start, end] = range;
onFilterChange("startDate", formatDateToYMD(start));
onFilterChange("endDate", formatDateToYMD(end));
}
};
const isPresetActive = (preset: string): boolean => {
if (!filters.startDate || !filters.endDate) return false;
const range = getDateRange(preset);
if (!range) return false;
const [expectedStart, expectedEnd] = range;
const expectedStartStr = formatDateToYMD(expectedStart);
const expectedEndStr = formatDateToYMD(expectedEnd);
return (
filters.startDate === expectedStartStr &&
filters.endDate === expectedEndStr
);
};
return (
<Stack gap="md">
{/* Quick Preset Buttons */}
<div>
<Text size="xs" fw={600} mb="xs" c="dimmed">
{t("audit.filters.quickPresets", "Quick filters")}
</Text>
<Group gap="xs">
<Button
variant={isPresetActive("today") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("today")}
disabled={disabled}
>
{t("audit.filters.today", "Today")}
</Button>
<Button
variant={isPresetActive("last7") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("last7")}
disabled={disabled}
>
{t("audit.filters.last7Days", "Last 7 days")}
</Button>
<Button
variant={isPresetActive("last30") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("last30")}
disabled={disabled}
>
{t("audit.filters.last30Days", "Last 30 days")}
</Button>
<Button
variant={isPresetActive("thisMonth") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("thisMonth")}
disabled={disabled}
>
{t("audit.filters.thisMonth", "This month")}
</Button>
</Group>
</div>
{/* Filter Inputs */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
<MultiSelect
placeholder={t("audit.events.filterByType", "Filter by type")}
data={eventTypes.map((type) => ({ value: type, label: type }))}
value={
Array.isArray(filters.eventType)
? filters.eventType
: filters.eventType
? [filters.eventType]
: []
}
onChange={(value) =>
onFilterChange("eventType", value.length > 0 ? value : undefined)
}
clearable
disabled={disabled}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<MultiSelect
placeholder={t("audit.events.filterByUser", "Filter by user")}
data={users.map((user) => ({ value: user, label: user }))}
value={
Array.isArray(filters.username)
? filters.username
: filters.username
? [filters.username]
: []
}
onChange={(value) =>
onFilterChange("username", value.length > 0 ? value : undefined)
}
clearable
searchable
disabled={disabled}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<DateInput
placeholder={t("audit.events.startDate", "Start date")}
value={filters.startDate ? new Date(filters.startDate) : null}
onChange={(value) => {
onFilterChange(
"startDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<DateInput
placeholder={t("audit.events.endDate", "End date")}
value={filters.endDate ? new Date(filters.endDate) : null}
onChange={(value) => {
onFilterChange(
"endDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</SimpleGrid>
{/* Clear Button */}
<Group justify="flex-end">
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
disabled={disabled}
>
{t("audit.events.clearFilters", "Clear")}
</Button>
</Group>
</Stack>
);
};
export default AuditFiltersForm;
@@ -0,0 +1,286 @@
import React, { useState, useEffect } from "react";
import {
Card,
Group,
Stack,
Text,
Badge,
SimpleGrid,
Loader,
Alert,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, { AuditStats } from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditStatsCardsProps {
loginEnabled?: boolean;
timePeriod: "day" | "week" | "month";
}
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({
loginEnabled = true,
timePeriod = "week",
}) => {
const { t } = useTranslation();
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await auditService.getStats(timePeriod);
setStats(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load statistics",
);
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchStats();
} else {
// Demo data when login disabled
setStats({
totalEvents: 4256,
prevTotalEvents: 3891,
uniqueUsers: 12,
prevUniqueUsers: 10,
successRate: 96.5,
prevSuccessRate: 94.2,
avgLatencyMs: 342,
prevAvgLatencyMs: 385,
errorCount: 148,
topEventType: "PDF_PROCESS",
topUser: "admin",
eventsByType: {},
eventsByUser: {},
topTools: {},
hourlyDistribution: {},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
</Card>
);
}
if (error) {
return (
<Alert
color="red"
title={t("audit.stats.error", "Error loading statistics")}
>
{error}
</Alert>
);
}
if (!stats) {
return null;
}
const trendPercent =
stats.prevTotalEvents > 0
? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) *
100
: 0;
const userTrend =
stats.prevUniqueUsers > 0
? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) *
100
: 0;
const latencyTrend =
stats.prevAvgLatencyMs > 0
? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) /
stats.prevAvgLatencyMs) *
100
: 0;
const successTrend =
stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
const getSuccessRateColor = (rate: number) => {
if (rate >= 95) return "green";
if (rate >= 80) return "yellow";
return "red";
};
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
if (lowerIsBetter) {
return trend <= 0 ? "green" : "red";
}
return trend >= 0 ? "green" : "red";
};
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
return isPositive ? "trending-up" : "trending-down";
};
return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{/* Total Events Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.totalEvents", "Total Events")}
</Text>
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.totalEvents.toLocaleString()}
</Text>
{trendPercent !== 0 && (
<Badge
color={getTrendColor(trendPercent)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(trendPercent)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(trendPercent).toFixed(1)}%{" "}
{t("audit.stats.vsLastPeriod", "vs last period")}
</Badge>
)}
</Stack>
</Card>
{/* Success Rate Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.successRate", "Success Rate")}
</Text>
<LocalIcon
icon="check-circle-rounded"
width="1.2rem"
height="1.2rem"
/>
</Group>
<Text size="xl" fw={700}>
{stats.successRate.toFixed(1)}%
</Text>
<Group gap="xs">
<Badge
color={getSuccessRateColor(stats.successRate)}
variant="light"
size="sm"
>
{stats.successRate >= 95
? t("audit.stats.excellent", "Excellent")
: stats.successRate >= 80
? t("audit.stats.good", "Good")
: t("audit.stats.attention", "Attention needed")}
</Badge>
{successTrend !== 0 && (
<Badge
color={getTrendColor(successTrend)}
variant="light"
size="xs"
>
{successTrend > 0 ? "+" : ""}
{successTrend.toFixed(1)}%
</Badge>
)}
</Group>
</Stack>
</Card>
{/* Active Users Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.activeUsers", "Active Users")}
</Text>
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.uniqueUsers}
</Text>
{userTrend !== 0 && (
<Badge
color={getTrendColor(userTrend)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(userTrend)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(userTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
{/* Avg Latency Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.avgLatency", "Avg Latency")}
</Text>
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.avgLatencyMs > 0
? `${stats.avgLatencyMs.toFixed(0)}ms`
: t("audit.stats.noData", "N/A")}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
color={getTrendColor(latencyTrend, true)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(latencyTrend, true)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(latencyTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
</SimpleGrid>
);
};
export default AuditStatsCards;
@@ -0,0 +1,137 @@
import React from "react";
import { Card, Group, Stack, Badge, Text, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AuditSystemStatus as AuditStatus } from "@app/services/auditService";
interface AuditSystemStatusProps {
status: AuditStatus;
}
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.systemStatus.title", "System Status")}
</Text>
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.status", "Audit Logging")}
</Text>
<Badge
color={status.enabled ? "green" : "red"}
variant="light"
size="lg"
mt="xs"
>
{status.enabled
? t("audit.systemStatus.enabled", "Enabled")
: t("audit.systemStatus.disabled", "Disabled")}
</Badge>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.level", "Audit Level")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.level}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.retention", "Retention Period")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.retentionDays} {t("audit.systemStatus.days", "days")}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.totalEvents", "Total Events")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.totalEvents.toLocaleString()}
</Text>
</div>
</Group>
<Divider />
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.systemStatus.capturedFields", "Captured Fields")}
</Text>
<Group gap="xs">
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.username", "Username")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.documentName", "Document Name")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.tool", "Tool")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.date", "Date")}
</Badge>
<Badge
color={status.capturePdfAuthor ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.pdfAuthor", "PDF Author")}
{!status.capturePdfAuthor && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
<Badge
color={status.captureFileHash ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.fileHash", "File Hash")}
{!status.captureFileHash && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
</Group>
</div>
</Stack>
</Card>
);
};
export default AuditSystemStatus;
@@ -0,0 +1,150 @@
import React, { useState, useMemo } from "react";
import { Button, Collapse, Select, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService, {
PlanTier,
PlanTierGroup,
LicenseInfo,
mapLicenseToTier,
} from "@app/services/licenseService";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
} from "@app/utils/planTierUtils";
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: { value: string; label: string }[];
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
onManageClick,
currency,
onCurrencyChange,
currencyOptions,
loginEnabled = true,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Group plans by tier (Free, Server, Enterprise)
const groupedPlans = useMemo(() => {
return licenseService.groupPlansByTier(plans);
}, [plans]);
// Calculate current tier from license info
const currentTier = useMemo(() => {
return mapLicenseToTier(currentLicenseInfo || null);
}, [currentLicenseInfo]);
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
return checkIsCurrentTier(currentTier, tierGroup.tier);
};
// Determine if selecting this plan would be a downgrade
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
return checkIsDowngrade(currentTier, tierGroup.tier);
};
return (
<div>
<Group justify="space-between" align="flex-start" mb="xs">
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 0 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.availablePlans.subtitle",
"Choose the plan that fits your needs",
)}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || "usd")}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
disabled={!loginEnabled}
/>
)}
</Group>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
marginBottom: "0.1rem",
}}
>
{groupedPlans.map((group) => (
<PlanCard
key={group.tier}
planGroup={group}
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
currentTier={currentTier}
onUpgradeClick={onUpgradeClick}
onManageClick={onManageClick}
loginEnabled={loginEnabled}
/>
))}
</div>
<div style={{ textAlign: "center" }}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable
plans={groupedPlans}
currentTier={currentTier}
/>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,107 @@
import React from "react";
import { Card, Badge, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanFeature } from "@app/services/licenseService";
interface PlanWithFeatures {
name: string;
features: PlanFeature[];
popular?: boolean;
tier?: string;
}
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: "free" | "server" | "enterprise" | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
plans,
currentTier,
}) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder style={{ marginTop: "1rem" }}>
<Text size="lg" fw={600} mb="md">
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr
style={{ borderBottom: "2px solid var(--mantine-color-gray-3)" }}
>
<th style={{ textAlign: "left", padding: "0.75rem" }}>
{t("plan.feature.title", "Feature")}
</th>
{plans.map((plan, index) => (
<th
key={plan.tier || plan.name || index}
style={{
textAlign: "center",
padding: "0.75rem",
minWidth: "8rem",
position: "relative",
}}
>
{plan.name}
{plan.popular &&
!(
plan.tier === "server" && currentTier === "enterprise"
) && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: "absolute",
top: "0.5rem",
right: "0.5rem",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0]?.features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<td style={{ padding: "0.75rem" }}>
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan, planIndex) => (
<td
key={planIndex}
style={{ textAlign: "center", padding: "0.75rem" }}
>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
</Text>
) : (
<Text c="gray" size="sm">
</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};
export default FeatureComparisonTable;
@@ -0,0 +1,359 @@
import React, { useState } from "react";
import {
Button,
Collapse,
Alert,
TextInput,
Paper,
Stack,
Group,
Text,
SegmentedControl,
FileButton,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { LicenseInfo } from "@app/services/licenseService";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>("");
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<"text" | "file">("text");
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === "file" && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === "text" && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key or file",
),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage =
inputMethod === "file"
? t(
"admin.settings.premium.file.successMessage",
"License file uploaded and activated successfully",
)
: t(
"admin.settings.premium.key.successMessage",
"License key activated successfully",
);
alert({
alertType: "success",
title: t("success", "Success"),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput("");
setLicenseFile(null);
setInputMethod("text"); // Reset to default
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error("Failed to save license:", error);
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
}
};
return (
<div>
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={
showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"
}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t(
"admin.settings.premium.licenseKey.toggle",
"Got a license key or certificate file?",
)}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.licenseKey.info",
"If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.",
)}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={
<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />
}
title={t(
"admin.settings.premium.key.overwriteWarning.title",
"⚠️ Warning: Existing License Detected",
)}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
"admin.settings.premium.key.overwriteWarning.line1",
"Overwriting your current license key cannot be undone.",
)}
</Text>
<Text size="sm">
{t(
"admin.settings.premium.key.overwriteWarning.line2",
"Your previous license will be permanently lost unless you have backed it up elsewhere.",
)}
</Text>
<Text size="sm" fw={500}>
{t(
"admin.settings.premium.key.overwriteWarning.line3",
"Important: Keep license keys private and secure. Never share them publicly.",
)}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t(
"admin.settings.premium.currentLicense.title",
"Active License",
)}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith("file:")
? t(
"admin.settings.premium.currentLicense.file",
"Source: License file ({{path}})",
{
path: currentLicenseInfo.licenseKey.substring(5),
},
)
: t(
"admin.settings.premium.currentLicense.key",
"Source: License key",
)}
</Text>
<Text size="xs">
{t(
"admin.settings.premium.currentLicense.type",
"Type: {{type}}",
{
type: currentLicenseInfo.licenseType,
},
)}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as "text" | "file");
// Clear opposite input when switching
if (value === "text") setLicenseFile(null);
if (value === "file") setLicenseKeyInput("");
}}
data={[
{
label: t(
"admin.settings.premium.inputMethod.text",
"License Key",
),
value: "text",
},
{
label: t(
"admin.settings.premium.inputMethod.file",
"Certificate File",
),
value: "file",
},
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === "text" ? (
/* Text input */
<TextInput
label={t("admin.settings.premium.key.label", "License Key")}
description={t(
"admin.settings.premium.key.description",
"Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={
currentLicenseInfo?.licenseKey ||
"00000000-0000-0000-0000-000000000000"
}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.premium.file.label",
"License Certificate File",
)}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t(
"admin.settings.premium.file.description",
"Upload your .lic or .cert license file",
)}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={
<LocalIcon
icon="upload-file-rounded"
width="1rem"
height="1rem"
/>
}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t(
"admin.settings.premium.file.choose",
"Choose License File",
)}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t(
"admin.settings.premium.file.selected",
"Selected: {{filename}} ({{size}})",
{
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + " KB",
},
)}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === "text" && !licenseKeyInput.trim()) ||
(inputMethod === "file" && !licenseFile)
}
>
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;
@@ -0,0 +1,222 @@
import React from "react";
import { Button, Card, Text, Stack, Divider, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup, LicenseInfo } from "@app/services/licenseService";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import { calculateDisplayPricing } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from "@app/utils/planTierUtils";
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
currentTier?: "free" | "server" | "enterprise" | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
planGroup,
isCurrentTier,
isDowngrade,
currentLicenseInfo,
currentTier,
onUpgradeClick,
onManageClick,
loginEnabled = true,
}) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === "free") {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || "$";
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
{isCurrentTier && (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
)}
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t("plan.from", "From")}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t("plan.free.forever", "Forever free")}
/>
</div>
<Divider />
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth className="plan-button">
{isCurrentTier
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
</Stack>
</Card>
);
}
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === "enterprise";
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(
currentTier,
planGroup.tier,
);
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } =
calculateDisplayPricing(monthly || undefined, yearly || undefined);
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
{isCurrentTier ? (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
) : planGroup.popular &&
!(planGroup.tier === "server" && currentTier === "enterprise") ? (
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
) : null}
<Stack gap="md" style={{ height: "100%" }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("plan.from", "From")}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}
{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t("plan.perSeat", "/seat")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("plan.perMonth", "/month")}{" "}
{t("plan.withServer", "+ Server Plan")}
</Text>
</>
) : (
<PriceDisplay
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t("plan.perMonth", "/month")}
/>
)}
</div>
<Divider />
{/* Highlights */}
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise &&
isCurrentTier &&
currentLicenseInfo &&
currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t("plan.licensedSeats", "Licensed: {{count}} seats", {
count: currentLicenseInfo.maxUsers,
})}
</Text>
)}
{/* Single Upgrade Button */}
<Tooltip
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
<Button
variant="filled"
fullWidth
onClick={() =>
isCurrentTier && onManageClick
? onManageClick()
: onUpgradeClick(planGroup)
}
disabled={
!loginEnabled || isDowngrade || isEnterpriseBlockedForFree
}
className="plan-button"
>
{isCurrentTier
? t("plan.manage", "Manage")
: isDowngrade
? t("plan.free.included", "Included")
: isEnterpriseBlockedForFree
? t("plan.enterprise.requiresServer", "Requires Server")
: isEnterprise
? t("plan.selectPlan", "Select Plan")
: t("plan.upgrade", "Upgrade")}
</Button>
</Tooltip>
</Stack>
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,392 @@
import React, { useState } from "react";
import {
Modal,
Text,
Group,
ActionIcon,
Stack,
Paper,
Grid,
TextInput,
Button,
Alert,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
STATIC_STRIPE_LINKS,
buildStripeUrlWithEmail,
} from "@app/constants/staticStripeLinks";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useIsMobile } from "@app/hooks/useIsMobile";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: "server" | "enterprise";
isUpgrade?: boolean;
}
type Stage = "email" | "period-selection" | "license-activation";
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>("email");
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState("");
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError("");
setStageHistory([...stageHistory, "email"]);
setStage("period-selection");
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: "monthly" | "yearly") => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, "_blank");
// Transition to license activation stage
setStageHistory([...stageHistory, "period-selection"]);
setStage("license-activation");
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key",
),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: "success",
title: t("success", "Success"),
body: t(
"admin.settings.premium.key.successMessage",
"License key activated successfully",
),
});
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error("Failed to save license:", error);
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
}
};
const handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage("email");
setEmail("");
setEmailError("");
setStageHistory([]);
setLicenseKey("");
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === "email") {
if (isUpgrade) {
return t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
return planName === "server"
? t("plan.static.getLicense", "Get Server License")
: t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
if (stage === "period-selection") {
return t("plan.static.selectPeriod", "Select Billing Period");
}
if (stage === "license-activation") {
return t("plan.static.activateLicense", "Activate Your License");
}
return "";
};
const renderContent = () => {
switch (stage) {
case "email":
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case "period-selection":
return (
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("monthly")}
>
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.monthly", "Monthly")}
</Text>
<Text size="sm" c="dimmed">
{t("plan.static.monthlyBilling", "Monthly Billing")}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("yearly")}
>
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
<Text size="sm" c="dimmed">
{t("plan.static.yearlyBilling", "Yearly Billing")}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case "license-activation":
return (
<Stack
gap="lg"
style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}
>
<Alert
variant="light"
color="blue"
icon={
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t(
"plan.static.licenseActivation.checkoutOpened",
"Checkout Opened in New Tab",
)}
</Text>
<Text size="sm">
{t(
"plan.static.licenseActivation.instructions",
"Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.",
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
title={t(
"plan.static.licenseActivation.success",
"License Activated!",
)}
>
<Text size="sm">
{t(
"plan.static.licenseActivation.successMessage",
"Your license has been successfully activated. You can now close this window.",
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
"plan.static.licenseActivation.enterKey",
"Enter your license key below to activate your plan:",
)}
</Text>
<TextInput
label={t("admin.settings.premium.key.label", "License Key")}
description={t(
"plan.static.licenseActivation.keyDescription",
"Paste the license key from your email",
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button
variant="subtle"
onClick={handleClose}
disabled={savingLicense}
>
{t(
"plan.static.licenseActivation.doLater",
"I'll do this later",
)}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t(
"plan.static.licenseActivation.activate",
"Activate License",
)}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t("common.close", "Close")}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== "license-activation";
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? "100%" : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;
@@ -0,0 +1,376 @@
import React, { useState } from "react";
import {
Card,
Text,
Stack,
Button,
Collapse,
Divider,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { STATIC_STRIPE_LINKS } from "@app/constants/staticStripeLinks";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
isEnterpriseBlockedForFree,
} from "@app/utils/planTierUtils";
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<"server" | "enterprise">(
"server",
);
const [isUpgrade, setIsUpgrade] = useState(false);
const handleOpenCheckout = (
plan: "server" | "enterprise",
upgrade: boolean,
) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === "free" && plan === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
}
setSelectedPlan(plan);
setIsUpgrade(upgrade);
setCheckoutModalOpened(true);
};
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: "warning",
title: t(
"plan.static.billingPortal.title",
"Email Verification Required",
),
body: t(
"plan.static.billingPortal.message",
"You will need to verify your email address in the Stripe billing portal. Check your email for a login link.",
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, "_blank");
};
const staticPlans = [
{
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: "server",
name: "Server",
price: 0,
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: "Unlimited users",
},
{
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: "Custom",
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === "enterprise") return staticPlans[2];
if (tier === "server") return staticPlans[1];
return staticPlans[0]; // free
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Available Plans */}
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.static.contactToUpgrade",
"Contact us to upgrade or customize your plan",
)}
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
paddingBottom: "0.1rem",
}}
>
{staticPlans.map((plan) => (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<PricingBadge
type="popular"
label={t("plan.popular", "Popular")}
/>
)}
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} style={{ fontSize: "2rem" }}>
{plan.name}
</Text>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === "string"
? plan.maxUsers
: `${t("plan.static.upTo", "Up to")} ${plan.maxUsers} ${t("workspace.people.license.users", "users")}`}
</Text>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(
currentLicenseInfo || null,
);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(
currentTier,
plan.id,
);
// Free Plan
if (plan.id === "free") {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
);
}
// Server Plan
if (plan.id === "server") {
if (currentTier === "free") {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout("server", false)}
className="plan-button"
>
{t("plan.upgrade", "Upgrade")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t("plan.manage", "Manage")}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t("plan.free.included", "Included")}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === "enterprise") {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip
label={t(
"plan.enterprise.requiresServer",
"Requires Server plan",
)}
position="top"
withArrow
>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t(
"plan.enterprise.requiresServer",
"Requires Server",
)}
</Button>
</Tooltip>
);
}
if (currentTier === "server") {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t("plan.contact", "Contact Us")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t("plan.manage", "Manage")}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
{/* Feature Comparison Table */}
<Collapse in={showComparison}>
<FeatureComparisonTable plans={staticPlans} />
</Collapse>
</div>
<Divider />
{/* License Key Section */}
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);
};
export default StaticPlanSection;
@@ -0,0 +1,97 @@
import React from "react";
import { Card, Text, Stack, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface SimpleBarChartProps {
data: { label: string; value: number }[];
maxValue: number;
}
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
{data.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("usage.noData", "No data available")}
</Text>
) : (
data.map((item, index) => (
<Box key={index}>
<Group justify="space-between" mb={4}>
<Text
size="xs"
c="dimmed"
maw="60%"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
</Text>
<Group gap="xs">
<Text size="xs" fw={600}>
{item.value}
</Text>
<Text size="xs" c="dimmed">
({((item.value / maxValue) * 100).toFixed(1)}%)
</Text>
</Group>
</Group>
<Box
style={{
width: "100%",
height: "0.5rem",
backgroundColor: "var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-sm)",
overflow: "hidden",
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: "100%",
backgroundColor: "var(--mantine-color-blue-6)",
transition: "width 0.3s ease",
}}
/>
</Box>
</Box>
))
)}
</Stack>
);
};
interface UsageAnalyticsChartProps {
data: { label: string; value: number }[];
}
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
const { t } = useTranslation();
const safeMaxValue = Math.max(
...data.map((d) => d.value).filter((value) => Number.isFinite(value)),
1,
);
const safeData = data.map((item) => ({
label: item.label,
value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0,
}));
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("usage.chart.title", "Endpoint Usage Chart")}
</Text>
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
</Stack>
</Card>
);
};
export default UsageAnalyticsChart;
@@ -0,0 +1,129 @@
import React from "react";
import {
Card,
Text,
Stack,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EndpointStatistic } from "@app/services/usageAnalyticsService";
interface UsageAnalyticsTableProps {
data: EndpointStatistic[];
}
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("usage.table.title", "Detailed Statistics")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<TableThead>
<TableTr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="5%"
>
#
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="55%"
>
{t("usage.table.endpoint", "Endpoint")}
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="20%"
ta="right"
>
{t("usage.table.visits", "Visits")}
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="20%"
ta="right"
>
{t("usage.table.percentage", "Percentage")}
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length === 0 ? (
<TableTr>
<TableTd colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t("usage.table.noData", "No data available")}
</Text>
</TableTd>
</TableTr>
) : (
data.map((stat, index) => (
<TableTr key={index}>
<TableTd>
<Text size="sm" c="dimmed">
{index + 1}
</Text>
</TableTd>
<TableTd>
<Text size="sm" truncate>
{stat.endpoint}
</Text>
</TableTd>
<TableTd ta="right">
<Text size="sm" fw={600}>
{stat.visits.toLocaleString()}
</Text>
</TableTd>
<TableTd ta="right">
<Text size="sm" c="dimmed">
{stat.percentage.toFixed(2)}%
</Text>
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
</Stack>
</Card>
);
};
export default UsageAnalyticsTable;
@@ -0,0 +1,63 @@
.text-divider {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
margin-top: 0.375rem; /* 6px */
margin-bottom: 0.5rem; /* 8px */
}
.text-divider .text-divider__rule {
height: 0.0625rem; /* 1px */
flex: 1 1 0%;
background-color: rgb(
var(--text-divider-rule-rgb, var(--gray-200)) /
var(--text-divider-opacity, 1)
);
}
.text-divider .text-divider__label {
color: rgb(
var(--text-divider-label-rgb, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
font-size: 0.75rem; /* 12px */
white-space: nowrap;
}
.text-divider.subcategory {
margin-top: 0;
margin-bottom: 0;
}
.text-divider.subcategory .text-divider__rule {
background-color: var(--tool-subcategory-rule-color);
}
.text-divider.subcategory .text-divider__label {
color: var(--tool-subcategory-text-color);
text-transform: uppercase;
font-weight: 600;
}
/* Force light theme colors regardless of dark mode */
.text-divider.force-light .text-divider__rule {
background-color: rgb(
var(--text-divider-rule-rgb-light, var(--gray-200)) /
var(--text-divider-opacity, 1)
);
}
.text-divider.force-light .text-divider__label {
color: rgb(
var(--text-divider-label-rgb-light, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
}
.text-divider.subcategory.force-light .text-divider__rule {
background-color: var(--tool-subcategory-rule-color-light);
}
.text-divider.subcategory.force-light .text-divider__label {
color: var(--tool-subcategory-text-color-light);
}
@@ -0,0 +1,69 @@
import { BASE_PATH } from "@app/constants/app";
import { getLogoFolder } from "@app/constants/logo";
import type { LogoVariant } from "@app/services/preferencesService";
import type { TFunction } from "i18next";
export type LoginCarouselSlide = {
src: string;
alt?: string;
title?: string;
subtitle?: string;
cornerModelUrl?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
export const buildLoginSlides = (
variant: LogoVariant | null | undefined,
t: TFunction,
): LoginCarouselSlide[] => {
const folder = getLogoFolder(variant);
const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
return [
{
src: heroImage,
alt: t("login.slides.overview.alt", "Stirling PDF overview"),
title: t(
"login.slides.overview.title",
"Your one-stop-shop for all your PDF needs.",
),
subtitle: t(
"login.slides.overview.subtitle",
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/AddToPDF.png`,
alt: t("login.slides.edit.alt", "Edit PDFs"),
title: t(
"login.slides.edit.title",
"Edit PDFs to display/secure the information you want",
),
subtitle: t(
"login.slides.edit.subtitle",
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/SecurePDF.png`,
alt: t("login.slides.secure.alt", "Secure PDFs"),
title: t(
"login.slides.secure.title",
"Protect sensitive information in your PDFs",
),
subtitle: t(
"login.slides.secure.subtitle",
"Add passwords, redact content, and manage certificates with ease.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
];
};
export default buildLoginSlides;
@@ -0,0 +1,311 @@
import React, { useEffect } from "react";
import { Modal, Text, Group, ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import licenseService from "@app/services/licenseService";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { StripeCheckoutProps } from "@app/components/shared/stripeCheckout/types/checkout";
import {
validateEmail,
getModalTitle,
} from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { calculateSavings } from "@app/components/shared/stripeCheckout/utils/savingsCalculator";
import { useCheckoutState } from "@app/components/shared/stripeCheckout/hooks/useCheckoutState";
import { useCheckoutNavigation } from "@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation";
import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/useLicensePolling";
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
"VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. " +
"Please add it to your .env file. " +
"Get your key from https://dashboard.stripe.com/apikeys",
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith("pk_")) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`,
);
}
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
// Initialize all state via custom hook
const checkoutState = useCheckoutState(planGroup);
// Initialize navigation hooks
const navigation = useCheckoutNavigation(
checkoutState.state,
checkoutState.setState,
checkoutState.stageHistory,
checkoutState.setStageHistory,
);
// Initialize license polling hook
const polling = useLicensePolling(
checkoutState.isMountedRef,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
onLicenseActivated,
);
// Initialize checkout session hook
const session = useCheckoutSession(
checkoutState.selectedPlan,
checkoutState.state,
checkoutState.setState,
checkoutState.installationId,
checkoutState.setInstallationId,
checkoutState.currentLicenseKey,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
minimumSeats,
polling.pollForLicenseKey,
onSuccess,
onError,
onLicenseActivated,
);
// Calculate savings
const savings = calculateSavings(planGroup, minimumSeats);
// Email submission handler
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState((prev) => ({
...prev,
email: checkoutState.emailInput,
}));
navigation.goToStage("plan-selection");
} else {
checkoutState.setEmailError(validation.error);
}
};
// Plan selection handler
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage("payment");
};
// Close handler
const handleClose = () => {
// Clear any active polling
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
checkoutState.resetState();
onClose();
};
// Cleanup on unmount
useEffect(() => {
checkoutState.isMountedRef.current = true;
return () => {
checkoutState.isMountedRef.current = false;
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
};
}, [checkoutState.isMountedRef, checkoutState.pollingTimeoutRef]);
// Initialize stage based on existing license
useEffect(() => {
if (!opened) return;
// Handle hosted checkout success - open directly to success state
if (hostedCheckoutSuccess) {
console.log("Opening modal to success state for hosted checkout return");
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
checkoutState.setCurrentLicenseKey("existing"); // Flag to indicate upgrade
checkoutState.setPollingStatus("ready");
} else if (hostedCheckoutSuccess.licenseKey) {
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
checkoutState.setPollingStatus("ready");
}
// Set to success state to show success UI
checkoutState.setState({ currentStage: "success", loading: false });
return;
}
// Check for existing license to skip email stage
const checkExistingLicense = async () => {
try {
const licenseInfo = await licenseService.getLicenseInfo();
// Only skip email if license is PRO or ENTERPRISE (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// Has valid premium license - skip email stage
console.log("Valid premium license detected - skipping email stage");
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey || null);
checkoutState.setState({
currentStage: "plan-selection",
loading: false,
});
} else {
// No valid premium license - start at email stage
checkoutState.setState({ currentStage: "email", loading: false });
}
} catch (error) {
console.warn("Could not check for existing license:", error);
// Default to email stage if check fails
checkoutState.setState({ currentStage: "email", loading: false });
}
};
checkExistingLicense();
}, [
opened,
hostedCheckoutSuccess,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
checkoutState.setState,
]);
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (
checkoutState.state.currentStage === "payment" &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
session.createCheckoutSession();
}
}, [
checkoutState.state.currentStage,
checkoutState.state.clientSecret,
checkoutState.state.loading,
session,
]);
// Render stage content
const renderContent = () => {
// Don't block checkout - hosted mode works without publishable key
// The checkout will automatically redirect to Stripe hosted page if key is missing
switch (checkoutState.state.currentStage) {
case "email":
return (
<EmailStage
emailInput={checkoutState.emailInput}
setEmailInput={checkoutState.setEmailInput}
emailError={checkoutState.emailError}
onSubmit={handleEmailSubmit}
/>
);
case "plan-selection":
return (
<PlanSelectionStage
planGroup={planGroup}
minimumSeats={minimumSeats}
savings={savings}
onSelectPlan={handlePlanSelect}
/>
);
case "payment":
return (
<PaymentStage
clientSecret={checkoutState.state.clientSecret || null}
selectedPlan={checkoutState.selectedPlan}
onPaymentComplete={session.handlePaymentComplete}
/>
);
case "success":
return (
<SuccessStage
pollingStatus={checkoutState.pollingStatus}
currentLicenseKey={checkoutState.currentLicenseKey}
licenseKey={checkoutState.licenseKey}
onClose={handleClose}
/>
);
case "error":
return (
<ErrorStage
error={checkoutState.state.error || "An unknown error occurred"}
onClose={handleClose}
/>
);
default:
return null;
}
};
const canGoBack = checkoutState.stageHistory.length > 0;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={navigation.goBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
</Text>
</Group>
}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {},
content: {
maxHeight: "95vh",
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
@@ -0,0 +1,101 @@
import React from "react";
import { Text, Stack } from "@mantine/core";
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { PRICE_FONT_WEIGHT } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface SimplePriceProps {
mode: "simple";
price: number;
currency: string;
period: string;
size?: string;
}
interface EnterprisePriceProps {
mode: "enterprise";
basePrice: number;
seatPrice: number;
totalPrice?: number;
currency: string;
period: "month" | "year";
seatCount?: number;
size?: "sm" | "md" | "lg";
}
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
if (props.mode === "simple") {
const fontSize = props.size || "2.25rem";
return (
<>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(props.price, props.currency)}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{props.period}
</Text>
</>
);
}
// Enterprise mode
const {
basePrice,
seatPrice,
totalPrice,
currency,
period,
seatCount,
size = "md",
} = props;
const fontSize = size === "lg" ? "2rem" : size === "sm" ? "md" : "xl";
const totalFontSize = size === "lg" ? "2rem" : "2rem";
return (
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed" mb="xs">
Base Price
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(basePrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/{period}
</Text>
</Text>
</div>
<div>
<Text size="sm" c="dimmed" mb="xs">
Per Seat
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(seatPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/seat/{period}
</Text>
</Text>
</div>
{totalPrice !== undefined && seatCount && (
<div>
<Text size="sm" c="dimmed" mb="xs">
Total ({seatCount} seats)
</Text>
<Text
size={totalFontSize}
fw={PRICE_FONT_WEIGHT}
style={{ lineHeight: 1 }}
>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/{period === "year" ? "month" : period}
</Text>
</Text>
</div>
)}
</Stack>
);
};
@@ -0,0 +1,25 @@
import React from "react";
import { Badge } from "@mantine/core";
interface PricingBadgeProps {
type: "current" | "popular" | "savings";
label: string;
savingsPercent?: number;
}
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
const color = type === "current" || type === "savings" ? "green" : "blue";
const size = type === "savings" ? "lg" : "sm";
return (
<Badge
color={color}
variant="filled"
size={size}
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
className={type === "current" ? "current-plan-badge" : undefined}
>
{label}
</Badge>
);
};
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import {
CheckoutState,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Stage navigation and history management hook
*/
export const useCheckoutNavigation = (
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
stageHistory: CheckoutStage[],
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>,
) => {
const goToStage = useCallback(
(nextStage: CheckoutStage) => {
setStageHistory((prev) => [...prev, state.currentStage]);
setState((prev) => ({ ...prev, currentStage: nextStage }));
},
[state.currentStage, setState, setStageHistory],
);
const goBack = useCallback(() => {
if (stageHistory.length > 0) {
const previousStage = stageHistory[stageHistory.length - 1];
setStageHistory((prev) => prev.slice(0, -1));
// Reset payment state when going back from payment stage
if (state.currentStage === "payment") {
setState((prev) => ({
...prev,
currentStage: previousStage,
clientSecret: undefined,
loading: false,
}));
} else {
setState((prev) => ({ ...prev, currentStage: previousStage }));
}
}
}, [stageHistory, state.currentStage, setState, setStageHistory]);
return { goToStage, goBack };
};
@@ -0,0 +1,174 @@
import { useCallback } from "react";
import licenseService, { PlanTier } from "@app/services/licenseService";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
import {
CheckoutState,
PollingStatus,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Checkout session creation and payment handling hook
*/
export const useCheckoutSession = (
selectedPlan: PlanTier | null,
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
installationId: string | null,
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
currentLicenseKey: string | null,
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
minimumSeats: number,
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const createCheckoutSession = useCallback(async () => {
if (!selectedPlan) {
setState({
currentStage: "error",
error: "Selected plan period is not available",
loading: false,
});
return;
}
try {
setState((prev) => ({ ...prev, loading: true }));
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
if (!fetchedInstallationId) {
fetchedInstallationId = await licenseService.getInstallationId();
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
// Only include if it's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (
licenseInfo?.licenseType &&
licenseInfo.licenseType !== "NORMAL" &&
licenseInfo.licenseKey
) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
console.log("Found existing valid license for upgrade");
}
} catch (error) {
console.warn(
"Could not fetch license info, proceeding as new license:",
error,
);
}
const response = await licenseService.createCheckoutSession({
lookup_key: selectedPlan.lookupKey,
installation_id: fetchedInstallationId,
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
email: state.email, // Pass collected email from Stage 1
});
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log("Redirecting to Stripe hosted checkout:", response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState((prev) => ({
...prev,
clientSecret: response.clientSecret,
sessionId: response.sessionId,
loading: false,
}));
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: "Failed to create checkout session";
setState({
currentStage: "error",
error: errorMessage,
loading: false,
});
onError?.(errorMessage);
}
}, [
selectedPlan,
state.email,
installationId,
minimumSeats,
setState,
setInstallationId,
setCurrentLicenseKey,
onError,
]);
const handlePaymentComplete = useCallback(async () => {
// Preserve state when changing stage
setState((prev) => ({ ...prev, currentStage: "success" }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log("Upgrade detected - resyncing existing license with Keygen");
setPollingStatus("polling");
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
onActivated: onLicenseActivated,
});
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus("ready");
} else {
console.error("Failed to sync upgraded license:", activation.error);
setPollingStatus("timeout");
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || "");
} else {
// NEW PLAN FLOW: Poll for new license key
console.log("New subscription - polling for license key");
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || "");
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || "");
}
}
}, [
currentLicenseKey,
installationId,
state.sessionId,
setState,
setPollingStatus,
pollForLicenseKey,
onSuccess,
onLicenseActivated,
]);
return {
createCheckoutSession,
handlePaymentComplete,
};
};
@@ -0,0 +1,83 @@
import { useState, useCallback, useRef } from "react";
import { PlanTierGroup } from "@app/services/licenseService";
import {
CheckoutState,
PollingStatus,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Centralized state management hook for checkout flow
*/
export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [state, setState] = useState<CheckoutState>({
currentStage: "email",
loading: false,
});
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
const [emailInput, setEmailInput] = useState<string>("");
const [emailError, setEmailError] = useState<string>("");
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
planGroup.yearly ? "yearly" : "monthly",
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
null,
);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
// Refs for polling cleanup
const isMountedRef = useRef(true);
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan =
selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const resetState = useCallback(() => {
setState({
currentStage: "email",
loading: false,
clientSecret: undefined,
sessionId: undefined,
error: undefined,
});
setStageHistory([]);
setEmailInput("");
setEmailError("");
setPollingStatus("idle");
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
}, [planGroup]);
return {
// State
state,
setState,
stageHistory,
setStageHistory,
emailInput,
setEmailInput,
emailError,
setEmailError,
selectedPeriod,
setSelectedPeriod,
installationId,
setInstallationId,
currentLicenseKey,
setCurrentLicenseKey,
licenseKey,
setLicenseKey,
pollingStatus,
setPollingStatus,
// Refs
isMountedRef,
pollingTimeoutRef,
// Computed
selectedPlan,
// Actions
resetState,
};
};
@@ -0,0 +1,52 @@
import { useCallback } from "react";
import {
pollLicenseKeyWithBackoff,
activateLicenseKey,
} from "@app/utils/licenseCheckoutUtils";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* License key polling and activation logic hook
*/
export const useLicensePolling = (
isMountedRef: React.RefObject<boolean>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const pollForLicenseKey = useCallback(
async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error("Failed to activate license key:", activation.error);
}
} else if (result.timedOut) {
console.warn("License key polling timed out");
} else if (result.error) {
console.error("License key polling failed:", result.error);
}
},
[isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated],
);
return { pollForLicenseKey };
};
@@ -0,0 +1,8 @@
export { default as StripeCheckout } from "@app/components/shared/stripeCheckout/StripeCheckout";
export type {
StripeCheckoutProps,
CheckoutStage,
CheckoutState,
PollingStatus,
SavingsCalculation,
} from "@app/components/shared/stripeCheckout/types/checkout";
@@ -0,0 +1,52 @@
import React from "react";
import { Stack, Text, TextInput, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface EmailStageProps {
emailInput: string;
setEmailInput: (email: string) => void;
emailError: string;
onSubmit: () => void;
}
export const EmailStage: React.FC<EmailStageProps> = ({
emailInput,
setEmailInput,
emailError,
onSubmit,
}) => {
const { t } = useTranslation();
return (
<Stack
gap="lg"
style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem 0" }}
>
<Text size="sm" c="dimmed">
{t(
"payment.emailStage.description",
"We'll use this to send your license key and receipts.",
)}
</Text>
<TextInput
label={t("payment.emailStage.emailLabel", "Email Address")}
placeholder={t("payment.emailStage.emailPlaceholder", "[email protected]")}
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
error={emailError}
size="lg"
required
onKeyDown={(e) => {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
<Button size="lg" onClick={onSubmit} disabled={!emailInput.trim()}>
{t("payment.emailStage.continue", "Continue")}
</Button>
</Stack>
);
};
@@ -0,0 +1,23 @@
import React from "react";
import { Alert, Stack, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ErrorStageProps {
error: string;
onClose: () => void;
}
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{error}</Text>
<Button variant="outline" onClick={onClose}>
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,68 @@
import React from "react";
import { Stack, Text, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface PaymentStageProps {
clientSecret: string | null;
selectedPlan: PlanTier | null;
onPaymentComplete: () => void;
}
export const PaymentStage: React.FC<PaymentStageProps> = ({
clientSecret,
selectedPlan,
onPaymentComplete,
}) => {
const { t } = useTranslation();
// Show loading while creating checkout session
if (!clientSecret || !selectedPlan) {
return (
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</Stack>
);
}
if (!stripePromise) {
// This should only happen if embedded mode was attempted without key
// Hosted checkout should have redirected before reaching this component
return (
<Stack align="center" gap="md" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.redirecting", "Redirecting to secure checkout...")}
</Text>
</Stack>
);
}
return (
<Stack gap="md">
{/* Stripe Embedded Checkout */}
<EmbeddedCheckoutProvider
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret: clientSecret,
onComplete: onPaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Stack>
);
};
@@ -0,0 +1,214 @@
import React from "react";
import {
Stack,
Button,
Text,
Grid,
Paper,
Alert,
Divider,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import {
formatPrice,
calculateMonthlyEquivalent,
calculateTotalWithSeats,
} from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
onSelectPlan: (period: "monthly" | "yearly") => void;
}
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
onSelectPlan,
}) => {
const { t } = useTranslation();
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => onSelectPlan("monthly")}
>
<Stack
gap="md"
style={{ height: "100%" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.monthly", "Monthly")}
</Text>
<Divider />
{isEnterprise && planGroup.monthly.seatPrice ? (
<PriceDisplay
mode="enterprise"
basePrice={planGroup.monthly.price}
seatPrice={planGroup.monthly.seatPrice}
totalPrice={calculateTotalWithSeats(
planGroup.monthly.price,
planGroup.monthly.seatPrice,
seatCount,
)}
currency={planGroup.monthly.currency}
period="month"
seatCount={seatCount}
size="sm"
/>
) : (
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="light" fullWidth size="lg">
{t("payment.planStage.selectMonthly", "Select Monthly")}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
{/* Yearly Option */}
{planGroup.yearly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan("yearly")}
>
{savings && (
<PricingBadge
type="savings"
label={t(
"payment.planStage.savePercent",
"Save {{percent}}%",
{ percent: savings.percent },
)}
/>
)}
<Stack
gap="md"
style={{ height: "100%" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
<Divider />
{isEnterprise && planGroup.yearly.seatPrice ? (
<Stack gap="sm">
<PriceDisplay
mode="enterprise"
basePrice={planGroup.yearly.price}
seatPrice={planGroup.yearly.seatPrice}
totalPrice={calculateMonthlyEquivalent(
calculateTotalWithSeats(
planGroup.yearly.price,
planGroup.yearly.seatPrice,
seatCount,
),
)}
currency={planGroup.yearly.currency}
period="year"
seatCount={seatCount}
size="sm"
/>
<Text size="sm" c="dimmed">
{t(
"payment.planStage.billedYearly",
"Billed yearly at {{currency}}{{amount}}",
{
currency: planGroup.yearly.currency,
amount: calculateTotalWithSeats(
planGroup.yearly.price,
planGroup.yearly.seatPrice,
seatCount,
).toFixed(2),
},
)}
</Text>
</Stack>
) : (
<Stack gap={0}>
<PriceDisplay
mode="simple"
price={calculateMonthlyEquivalent(
planGroup.yearly?.price || 0,
)}
currency={planGroup.yearly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
<Text size="sm" c="dimmed" mt="xs">
{t(
"payment.planStage.billedYearly",
"Billed yearly at {{currency}}{{amount}}",
{
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2),
},
)}
</Text>
</Stack>
)}
{savings && (
<Alert color="green" variant="light" p="sm">
<Text size="sm" fw={600}>
{t(
"payment.planStage.savingsAmount",
"You save {{amount}}",
{
amount: formatPrice(savings.amount, savings.currency),
},
)}
</Text>
</Alert>
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="filled" fullWidth size="lg">
{t("payment.planStage.selectYearly", "Select Yearly")}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
</Grid>
</Stack>
);
};
@@ -0,0 +1,122 @@
import React from "react";
import {
Alert,
Stack,
Text,
Paper,
Code,
Button,
Group,
Loader,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
interface SuccessStageProps {
pollingStatus: PollingStatus;
currentLicenseKey: string | null;
licenseKey: string | null;
onClose: () => void;
}
export const SuccessStage: React.FC<SuccessStageProps> = ({
pollingStatus,
currentLicenseKey,
licenseKey,
onClose,
}) => {
const { t } = useTranslation();
return (
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
<Stack gap="md">
<Text size="sm">
{t(
"payment.successMessage",
"Your subscription has been activated successfully.",
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === "polling" && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t(
"payment.syncingLicense",
"Syncing your upgraded license...",
)
: t(
"payment.generatingLicense",
"Generating your license key...",
)}
</Text>
</Group>
)}
{pollingStatus === "ready" && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t("payment.licenseKey", "Your License Key")}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t("common.copy", "Copy to Clipboard")}
</Button>
<Text size="xs" c="dimmed">
{t(
"payment.licenseInstructions",
"This has been added to your installation. You will receive a copy in your email as well.",
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === "ready" && currentLicenseKey && (
<Alert
color="green"
title={t("payment.upgradeComplete", "Upgrade Complete")}
>
<Text size="sm">
{t(
"payment.upgradeCompleteMessage",
"Your subscription has been upgraded successfully. Your existing license key has been updated.",
)}
</Text>
</Alert>
)}
{pollingStatus === "timeout" && (
<Alert
color="yellow"
title={t("payment.licenseDelayed", "License Key Processing")}
>
<Text size="sm">
{t(
"payment.licenseDelayedMessage",
"Your license key is being generated. Please check your email shortly or contact support.",
)}
</Text>
</Alert>
)}
{pollingStatus === "ready" && (
<Text size="xs" c="dimmed">
{t("payment.canCloseWindow", "You can now close this window.")}
</Text>
)}
<Button onClick={onClose} mt="md">
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,44 @@
import { PlanTierGroup } from "@app/services/licenseService";
export interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
export type CheckoutStage =
| "email"
| "plan-selection"
| "payment"
| "success"
| "error";
export type CheckoutState = {
currentStage: CheckoutStage;
email?: string;
clientSecret?: string;
error?: string;
sessionId?: string;
loading?: boolean;
};
export type PollingStatus = "idle" | "polling" | "ready" | "timeout";
export interface SavingsCalculation {
amount: number;
percent: number;
currency: string;
}
@@ -0,0 +1,48 @@
import { CSSProperties } from "react";
/**
* Shared styling utilities for plan cards
*/
export const CARD_MIN_HEIGHT = "400px";
export const PRICE_FONT_WEIGHT = 600;
/**
* Get card border style based on state
*/
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
return {
borderColor: isHighlighted ? "var(--mantine-color-green-6)" : undefined,
borderWidth: isHighlighted ? "2px" : undefined,
};
}
/**
* Get base card style
*/
export function getBaseCardStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
position: "relative",
display: "flex",
flexDirection: "column",
minHeight: CARD_MIN_HEIGHT,
...getCardBorderStyle(isHighlighted),
};
}
/**
* Get clickable paper style
*/
export function getClickablePaperStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
cursor: "pointer",
transition: "all 0.2s",
height: "100%",
position: "relative",
...getCardBorderStyle(isHighlighted),
};
}
@@ -0,0 +1,52 @@
import { TFunction } from "i18next";
import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Validate email address format
*/
export const validateEmail = (
email: string,
): { valid: boolean; error: string } => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return {
valid: false,
error: "Please enter a valid email address",
};
}
return { valid: true, error: "" };
};
/**
* Get dynamic modal title based on current stage
*/
export const getModalTitle = (
stage: CheckoutStage,
planName: string,
t: TFunction,
): string => {
switch (stage) {
case "email":
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", {
planName,
});
case "plan-selection":
return t(
"payment.planStage.modalTitle",
"Select Billing Period - {{planName}}",
{ planName },
);
case "payment":
return t(
"payment.paymentStage.modalTitle",
"Complete Payment - {{planName}}",
{ planName },
);
case "success":
return t("payment.success", "Payment Successful!");
case "error":
return t("payment.error", "Payment Error");
default:
return t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName });
}
};
@@ -0,0 +1,66 @@
/**
* Shared pricing utilities for plan cards and checkout
*/
export interface PriceCalculation {
displayPrice: number;
displaySeatPrice?: number;
displayCurrency: string;
}
/**
* Calculate monthly equivalent from yearly price
*/
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
return yearlyPrice / 12;
}
/**
* Calculate total price including seats
*/
export function calculateTotalWithSeats(
basePrice: number,
seatPrice: number | undefined,
seatCount: number,
): number {
if (seatPrice === undefined) return basePrice;
return basePrice + seatPrice * seatCount;
}
/**
* Format price with currency symbol
*/
export function formatPrice(
amount: number,
currency: string,
decimals: number = 2,
): string {
return `${currency}${amount.toFixed(decimals)}`;
}
/**
* Calculate display pricing for a plan, showing yearly price divided by 12
* to show the lowest monthly equivalent
*/
export function calculateDisplayPricing(
monthly?: { price: number; seatPrice?: number; currency: string },
yearly?: { price: number; seatPrice?: number; currency: string },
): PriceCalculation {
// Default to monthly if no yearly exists
if (!yearly) {
return {
displayPrice: monthly?.price || 0,
displaySeatPrice: monthly?.seatPrice,
displayCurrency: monthly?.currency || "£",
};
}
// Use yearly price divided by 12 for best value display
return {
displayPrice: calculateMonthlyEquivalent(yearly.price),
displaySeatPrice: yearly.seatPrice
? calculateMonthlyEquivalent(yearly.seatPrice)
: undefined,
displayCurrency: yearly.currency,
};
}
@@ -0,0 +1,44 @@
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Calculate savings for yearly vs monthly plans
* Returns null if both monthly and yearly plans are not available
*/
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number,
): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
let monthlyAnnual: number;
let yearlyTotal: number;
if (
isEnterprise &&
planGroup.monthly.seatPrice &&
planGroup.yearly.seatPrice
) {
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
monthlyAnnual =
(planGroup.monthly.price + planGroup.monthly.seatPrice * seatCount) * 12;
yearlyTotal =
planGroup.yearly.price + planGroup.yearly.seatPrice * seatCount;
} else {
// Server: price * 12 vs yearly price
monthlyAnnual = planGroup.monthly.price * 12;
yearlyTotal = planGroup.yearly.price;
}
const savings = monthlyAnnual - yearlyTotal;
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
return {
amount: savings,
percent: savingsPercent,
currency: planGroup.yearly.currency,
};
};