mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
AppProviders as CoreAppProviders,
|
||||
AppProvidersProps,
|
||||
} from "@core/components/AppProviders";
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
|
||||
export function AppProviders({
|
||||
children,
|
||||
appConfigRetryOptions,
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
return (
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<AuthProvider>
|
||||
<LicenseProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<ServerExperienceProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBannerInitializer />
|
||||
{children}
|
||||
</CheckoutProvider>
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
</LicenseProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
+783
@@ -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;
|
||||
+1790
File diff suppressed because it is too large
Load Diff
+283
@@ -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;
|
||||
+995
@@ -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>
|
||||
);
|
||||
}
|
||||
+1125
File diff suppressed because it is too large
Load Diff
+435
@@ -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>
|
||||
);
|
||||
}
|
||||
+376
@@ -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>
|
||||
);
|
||||
}
|
||||
+1336
File diff suppressed because it is too large
Load Diff
+285
@@ -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>
|
||||
);
|
||||
}
|
||||
+334
@@ -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>
|
||||
);
|
||||
}
|
||||
+241
@@ -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;
|
||||
+235
@@ -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>
|
||||
);
|
||||
}
|
||||
+306
@@ -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>
|
||||
);
|
||||
}
|
||||
+1580
File diff suppressed because it is too large
Load Diff
+415
@@ -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>
|
||||
);
|
||||
}
|
||||
+445
@@ -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>
|
||||
);
|
||||
}
|
||||
+1037
File diff suppressed because it is too large
Load Diff
+838
@@ -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>
|
||||
);
|
||||
}
|
||||
+642
@@ -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>
|
||||
);
|
||||
}
|
||||
+102
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+57
@@ -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>
|
||||
);
|
||||
}
|
||||
+161
@@ -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;
|
||||
+329
@@ -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;
|
||||
+245
@@ -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;
|
||||
+564
@@ -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;
|
||||
+269
@@ -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;
|
||||
+231
@@ -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;
|
||||
+286
@@ -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;
|
||||
+137
@@ -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;
|
||||
+150
@@ -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;
|
||||
+107
@@ -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;
|
||||
+359
@@ -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;
|
||||
+222
@@ -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;
|
||||
+392
@@ -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;
|
||||
+376
@@ -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;
|
||||
+97
@@ -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;
|
||||
+129
@@ -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;
|
||||
+101
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
@@ -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>
|
||||
);
|
||||
};
|
||||
+44
@@ -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 };
|
||||
};
|
||||
+174
@@ -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,
|
||||
};
|
||||
};
|
||||
+83
@@ -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,
|
||||
};
|
||||
};
|
||||
+52
@@ -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";
|
||||
+52
@@ -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>
|
||||
);
|
||||
};
|
||||
+23
@@ -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>
|
||||
);
|
||||
};
|
||||
+68
@@ -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>
|
||||
);
|
||||
};
|
||||
+214
@@ -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>
|
||||
);
|
||||
};
|
||||
+122
@@ -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),
|
||||
};
|
||||
}
|
||||
+52
@@ -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 });
|
||||
}
|
||||
};
|
||||
+66
@@ -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,
|
||||
};
|
||||
}
|
||||
+44
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Card,
|
||||
Text,
|
||||
Badge,
|
||||
Group,
|
||||
Button,
|
||||
Loader,
|
||||
Alert,
|
||||
TextInput,
|
||||
FileInput,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import { useParticipantSession } from "@app/hooks/workflow/useParticipantSession";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
|
||||
interface ParticipantViewProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
session,
|
||||
participant,
|
||||
loading,
|
||||
error,
|
||||
submitSignature,
|
||||
decline,
|
||||
downloadDocument,
|
||||
} = useParticipantSession(token);
|
||||
|
||||
const [certType, setCertType] = useState<string>("P12");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [location, setLocation] = useState<string>("");
|
||||
const [reason, setReason] = useState<string>("Document Signing");
|
||||
const [showSignature, _setShowSignature] = useState<boolean>(true);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [declineReason, _setDeclineReason] = useState<string>("");
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [notification, setNotification] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
type CertValidationState =
|
||||
| { status: "idle" }
|
||||
| { status: "validating" }
|
||||
| { status: "valid"; notAfter: string | null; subjectName: string | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
const [certValidation, setCertValidation] = useState<CertValidationState>({
|
||||
status: "idle",
|
||||
});
|
||||
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (certType === "SERVER" || !certFile) {
|
||||
setCertValidation({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||
setCertValidation({ status: "validating" });
|
||||
|
||||
validationTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("participantToken", token);
|
||||
formData.append("certType", certType);
|
||||
formData.append("password", password);
|
||||
if (certType === "JKS") {
|
||||
formData.append("jksFile", certFile);
|
||||
} else {
|
||||
formData.append("p12File", certFile);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"/api/v1/workflow/participant/validate-certificate",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.valid) {
|
||||
setCertValidation({
|
||||
status: "valid",
|
||||
notAfter: data.notAfter,
|
||||
subjectName: data.subjectName,
|
||||
});
|
||||
} else {
|
||||
setCertValidation({
|
||||
status: "error",
|
||||
message:
|
||||
data.error ??
|
||||
t(
|
||||
"certSign.collab.participant.certInvalidFallback",
|
||||
"Invalid certificate",
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setCertValidation({
|
||||
status: "error",
|
||||
message: t(
|
||||
"certSign.collab.participant.certNetworkError",
|
||||
"Could not validate certificate",
|
||||
),
|
||||
});
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||
};
|
||||
}, [certFile, password, certType, token]);
|
||||
|
||||
const handleSubmitSignature = async () => {
|
||||
if (!certFile && certType !== "SERVER") {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: "Please select a certificate file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setNotification(null);
|
||||
try {
|
||||
await submitSignature({
|
||||
participantToken: token,
|
||||
certType,
|
||||
password,
|
||||
p12File: certType === "P12" ? certFile || undefined : undefined,
|
||||
jksFile: certType === "JKS" ? certFile || undefined : undefined,
|
||||
showSignature,
|
||||
pageNumber,
|
||||
location,
|
||||
reason,
|
||||
showLogo: true,
|
||||
});
|
||||
setNotification({
|
||||
type: "success",
|
||||
message: "Signature submitted successfully!",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: `Failed to submit signature: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (
|
||||
window.confirm("Are you sure you want to decline signing this document?")
|
||||
) {
|
||||
setNotification(null);
|
||||
try {
|
||||
await decline(token, declineReason || "Declined by participant");
|
||||
setNotification({
|
||||
type: "success",
|
||||
message: "You have declined this signing request.",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !session) {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed">Loading session...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !participant) {
|
||||
return (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||
Session not found or access denied.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "SIGNED":
|
||||
return <Badge color="green">Signed</Badge>;
|
||||
case "DECLINED":
|
||||
return <Badge color="red">Declined</Badge>;
|
||||
case "VIEWED":
|
||||
return <Badge color="blue">Viewed</Badge>;
|
||||
case "NOTIFIED":
|
||||
return <Badge color="yellow">Notified</Badge>;
|
||||
case "PENDING":
|
||||
return <Badge color="gray">Pending</Badge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const canSign =
|
||||
!participant.hasCompleted &&
|
||||
!participant.isExpired &&
|
||||
session.status === "IN_PROGRESS";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{notification && (
|
||||
<Alert
|
||||
icon={
|
||||
notification.type === "success" ? (
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
) : (
|
||||
<InfoIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
color={notification.type === "success" ? "green" : "red"}
|
||||
withCloseButton
|
||||
onClose={() => setNotification(null)}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
)}
|
||||
<Card shadow="sm" padding="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={500} size="lg">
|
||||
{session.documentName}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
From: {session.ownerUsername}
|
||||
</Text>
|
||||
</div>
|
||||
{getStatusBadge(participant.status)}
|
||||
</Group>
|
||||
|
||||
{session.message && (
|
||||
<Alert
|
||||
icon={<InfoIcon fontSize="small" />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">{session.message}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{session.dueDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Due Date: {session.dueDate}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => downloadDocument(token)}
|
||||
variant="light"
|
||||
>
|
||||
Download Document
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{canSign && (
|
||||
<Card shadow="sm" padding="md" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={500} size="lg">
|
||||
Sign Document
|
||||
</Text>
|
||||
|
||||
<Select
|
||||
label="Certificate Type"
|
||||
value={certType}
|
||||
onChange={(value) => setCertType(value || "P12")}
|
||||
data={[
|
||||
{ value: "P12", label: "P12/PKCS12 Certificate" },
|
||||
{ value: "JKS", label: "JKS Keystore" },
|
||||
{ value: "SERVER", label: "Server Certificate (if available)" },
|
||||
]}
|
||||
size="sm"
|
||||
data-testid="cert-type-select"
|
||||
/>
|
||||
|
||||
{certType !== "SERVER" && (
|
||||
<>
|
||||
<FileInput
|
||||
label="Certificate File"
|
||||
placeholder="Select certificate file"
|
||||
value={certFile}
|
||||
onChange={setCertFile}
|
||||
accept=".p12,.pfx,.jks"
|
||||
size="sm"
|
||||
data-testid="cert-file-input"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Certificate Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
size="sm"
|
||||
data-testid="cert-password-input"
|
||||
/>
|
||||
|
||||
{/* Certificate validation feedback */}
|
||||
{certValidation.status === "validating" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certValidating",
|
||||
"Validating certificate...",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{certValidation.status === "valid" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="green"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certValid",
|
||||
"✓ Certificate valid",
|
||||
)}
|
||||
{certValidation.notAfter
|
||||
? t(
|
||||
"certSign.collab.participant.certValidUntil",
|
||||
" until {{date}}",
|
||||
{
|
||||
date: new Date(
|
||||
certValidation.notAfter,
|
||||
).toLocaleDateString(),
|
||||
},
|
||||
)
|
||||
: ""}
|
||||
{certValidation.subjectName
|
||||
? ` · ${certValidation.subjectName}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{certValidation.status === "error" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="red"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certInvalid",
|
||||
"✗ {{error}}",
|
||||
{ error: certValidation.message },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="e.g., San Francisco, CA"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder="e.g., Document approval"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Page Number (optional)"
|
||||
type="number"
|
||||
value={pageNumber}
|
||||
onChange={(e) =>
|
||||
setPageNumber(parseInt(e.currentTarget.value) || 1)
|
||||
}
|
||||
size="sm"
|
||||
min={1}
|
||||
/>
|
||||
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon fontSize="small" />}
|
||||
onClick={handleSubmitSignature}
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting || certValidation.status === "validating"
|
||||
}
|
||||
color="green"
|
||||
data-testid="submit-signature-button"
|
||||
>
|
||||
Submit Signature
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
onClick={handleDecline}
|
||||
color="red"
|
||||
variant="light"
|
||||
data-testid="decline-button"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{participant.hasCompleted && (
|
||||
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
|
||||
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
|
||||
this document.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{participant.isExpired && (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||
Your access to this document has expired.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantView;
|
||||
Reference in New Issue
Block a user