Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -1,6 +1,6 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EnterpriseRequiredBannerProps {
show: boolean;
@@ -18,20 +18,20 @@ export default function EnterpriseRequiredBanner({ show, featureName }: Enterpri
return (
<Alert
icon={<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />}
title={t('admin.settings.enterpriseRequired.title', 'Enterprise License Required')}
title={t("admin.settings.enterpriseRequired.title", "Enterprise License Required")}
color="yellow"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-yellow-6)'
}
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 }
"admin.settings.enterpriseRequired.message",
"An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.",
{ featureName },
)}
</Text>
</Alert>
@@ -1,7 +1,7 @@
import { Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
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();
@@ -11,19 +11,21 @@ export function OverviewHeader() {
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
navigate("/login");
} catch (error) {
console.error('Logout error:', error);
console.error("Logout error:", error);
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<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 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.')}
{t("config.overview.description", "Current application settings and configuration details.")}
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
@@ -1,24 +1,28 @@
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';
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
@@ -26,7 +30,7 @@ import GeneralSection from '@app/components/shared/config/configSections/General
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -34,18 +38,18 @@ export const useConfigNavSections = (
const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add account management under Preferences
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === 'general'));
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
item.key === "general" ? { ...item, component: <GeneralSection /> } : item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: 'account',
label: t('account.accountSettings', 'Account'),
icon: 'person-rounded',
component: <AccountSection />
key: "account",
label: t("account.accountSettings", "Account"),
icon: "person-rounded",
component: <AccountSection />,
});
}
}
@@ -53,162 +57,162 @@ export const useConfigNavSections = (
// 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');
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'),
title: t("settings.workspace.title", "Workspace"),
items: [
{
key: 'people',
label: t('settings.workspace.people', 'People'),
icon: 'group-rounded',
key: "people",
label: t("settings.workspace.people", "People"),
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'teams',
label: t('settings.workspace.teams', 'Teams'),
icon: 'groups-rounded',
key: "teams",
label: t("settings.workspace.teams", "Teams"),
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Configuration
sections.push({
title: t('settings.configuration.title', 'Configuration'),
title: t("settings.configuration.title", "Configuration"),
items: [
{
key: 'adminGeneral',
label: t('settings.configuration.systemSettings', 'System Settings'),
icon: 'settings-rounded',
key: "adminGeneral",
label: t("settings.configuration.systemSettings", "System Settings"),
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminFeatures',
label: t('settings.configuration.features', 'Features'),
icon: 'extension-rounded',
key: "adminFeatures",
label: t("settings.configuration.features", "Features"),
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminStorageSharing',
label: t('settings.configuration.storageSharing', 'File Storage & Sharing'),
icon: 'storage-rounded',
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',
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
{
key: 'adminEndpoints',
label: t('settings.configuration.endpoints', 'Endpoints'),
icon: 'api-rounded',
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminDatabase',
label: t('settings.configuration.database', 'Database'),
icon: 'storage-rounded',
key: "adminDatabase",
label: t("settings.configuration.database", "Database"),
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminAdvanced',
label: t('settings.configuration.advanced', 'Advanced'),
icon: 'tune-rounded',
key: "adminAdvanced",
label: t("settings.configuration.advanced", "Advanced"),
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t('settings.securityAuth.title', 'Security & Authentication'),
title: t("settings.securityAuth.title", "Security & Authentication"),
items: [
{
key: 'adminSecurity',
label: t('settings.securityAuth.security', 'Security'),
icon: 'shield-rounded',
key: "adminSecurity",
label: t("settings.securityAuth.security", "Security"),
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminConnections',
label: t('settings.securityAuth.connections', 'Connections'),
icon: 'link-rounded',
key: "adminConnections",
label: t("settings.securityAuth.connections", "Connections"),
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: t('settings.licensingAnalytics.title', 'Licensing & Analytics'),
title: t("settings.licensingAnalytics.title", "Licensing & Analytics"),
items: [
{
key: 'adminPlan',
label: t('settings.licensingAnalytics.plan', 'Plan'),
icon: 'star-rounded',
key: "adminPlan",
label: t("settings.licensingAnalytics.plan", "Plan"),
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminAudit',
label: t('settings.licensingAnalytics.audit', 'Audit'),
icon: 'fact-check-rounded',
key: "adminAudit",
label: t("settings.licensingAnalytics.audit", "Audit"),
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip,
},
{
key: 'adminUsage',
label: t('settings.licensingAnalytics.usageAnalytics', 'Usage Analytics'),
icon: 'analytics-rounded',
key: "adminUsage",
label: t("settings.licensingAnalytics.usageAnalytics", "Usage Analytics"),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip,
},
],
});
// Policies & Privacy
sections.push({
title: t('settings.policiesPrivacy.title', 'Policies & Privacy'),
title: t("settings.policiesPrivacy.title", "Policies & Privacy"),
items: [
{
key: 'adminLegal',
label: t('settings.policiesPrivacy.legal', 'Legal'),
icon: 'gavel-rounded',
key: "adminLegal",
label: t("settings.policiesPrivacy.legal", "Legal"),
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminPrivacy',
label: t('settings.policiesPrivacy.privacy', 'Privacy'),
icon: 'visibility-rounded',
key: "adminPrivacy",
label: t("settings.policiesPrivacy.privacy", "Privacy"),
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
@@ -217,13 +221,13 @@ export const useConfigNavSections = (
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: t('settings.developer.title', 'Developer'),
title: t("settings.developer.title", "Developer"),
items: [
{
key: 'api-keys',
label: t('settings.developer.apiKeys', 'API Keys'),
icon: 'key-rounded',
component: <ApiKeys />
key: "api-keys",
label: t("settings.developer.apiKeys", "API Keys"),
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
@@ -243,26 +247,26 @@ export const useConfigNavSections = (
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn('createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.');
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'));
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
item.key === "general" ? { ...item, component: <GeneralSection /> } : item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: 'account',
label: 'Account',
icon: 'person-rounded',
component: <AccountSection />
key: "account",
label: "Account",
icon: "person-rounded",
component: <AccountSection />,
});
}
}
@@ -273,147 +277,147 @@ export const createConfigNavSections = (
// Workspace
sections.push({
title: 'Workspace',
title: "Workspace",
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
key: "people",
label: "People",
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Configuration
sections.push({
title: 'Configuration',
title: "Configuration",
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
key: "adminGeneral",
label: "System Settings",
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
key: "adminFeatures",
label: "Features",
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
key: "adminEndpoints",
label: "Endpoints",
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
key: "adminDatabase",
label: "Database",
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
key: "adminAdvanced",
label: "Advanced",
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
title: "Security & Authentication",
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
key: "adminSecurity",
label: "Security",
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
key: "adminConnections",
label: "Connections",
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
title: "Licensing & Analytics",
items: [
{
key: 'adminPlan',
label: 'Plan',
icon: 'star-rounded',
key: "adminPlan",
label: "Plan",
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
key: "adminAudit",
label: "Audit",
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
disabledTooltip: requiresLogin ? "Enable login mode first" : "Requires Enterprise license",
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
key: "adminUsage",
label: "Usage Analytics",
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license'
disabledTooltip: requiresLogin ? "Enable login mode first" : "Requires Enterprise license",
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
title: "Policies & Privacy",
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
key: "adminLegal",
label: "Legal",
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
key: "adminPrivacy",
label: "Privacy",
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
@@ -422,13 +426,13 @@ export const createConfigNavSections = (
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: 'Developer',
title: "Developer",
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
component: <ApiKeys />
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
@@ -442,4 +446,4 @@ export const createConfigNavSections = (
};
// Re-export types for convenience
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from "@core/components/shared/config/configNavSections";
@@ -1,15 +1,15 @@
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';
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();
@@ -18,26 +18,26 @@ const AccountSection: React.FC = () => {
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 [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 [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 [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 normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const authTypeFromMetadata = useMemo(() => {
@@ -46,15 +46,15 @@ const AccountSection: React.FC = () => {
}, [user?.app_metadata]);
const normalizedAuthType = useMemo(
() => (user?.authenticationType ?? authTypeFromMetadata ?? '').toLowerCase(),
[authTypeFromMetadata, user?.authenticationType]
() => (user?.authenticationType ?? authTypeFromMetadata ?? "").toLowerCase(),
[authTypeFromMetadata, user?.authenticationType],
);
const isSsoUser = useMemo(() => ['sso', 'oauth2', 'saml2'].includes(normalizedAuthType), [normalizedAuthType]);
const isSsoUser = useMemo(() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType), [normalizedAuthType]);
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
const userIdentifier = useMemo(() => user?.email || user?.username || "", [user?.email, user?.username]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
window.location.assign("/login");
}, []);
const handleLogout = useCallback(async () => {
@@ -65,41 +65,44 @@ const AccountSection: React.FC = () => {
event.preventDefault();
if (isSsoUser) {
setPasswordError(t('settings.security.password.ssoDisabled', 'Password changes are managed by your identity provider.'));
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.'));
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.'));
setPasswordError(t("settings.security.password.mismatch", "New passwords do not match."));
return;
}
try {
setPasswordSubmitting(true);
setPasswordError('');
setPasswordError("");
await accountService.changePassword(currentPassword, newPassword);
showToast({
alertType: 'success',
title: t('settings.security.password.success', 'Password updated successfully. Please sign in again.'),
alertType: "success",
title: t("settings.security.password.success", "Password updated successfully. Please sign in again."),
});
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
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.')
t(
"settings.security.password.error",
"Unable to update password. Please verify your current password and try again.",
),
);
} finally {
setPasswordSubmitting(false);
@@ -110,13 +113,16 @@ const AccountSection: React.FC = () => {
const fetchAccountData = async () => {
setChangeButtonDisabled(true);
try {
const data = await accountService.getAccountData().then((data) => data).finally(() => {
setChangeButtonDisabled(false);
});
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');
console.warn("Failed to fetch account data");
} finally {
setChangeButtonDisabled(false);
}
@@ -127,8 +133,8 @@ const AccountSection: React.FC = () => {
const handleStartMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError('');
setMfaSetupCode('');
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
setMfaSetupModalOpen(true);
@@ -136,7 +142,7 @@ const AccountSection: React.FC = () => {
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.')
t("account.mfa.setupFailed", "Unable to start two-factor setup. Please try again."),
);
} finally {
setMfaLoading(false);
@@ -147,117 +153,117 @@ const AccountSection: React.FC = () => {
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
setMfaError(t("account.mfa.codeRequired", "Enter the authentication code to continue."));
return;
}
try {
setMfaLoading(true);
setMfaError('');
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setMfaEnabled(true);
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
setMfaSetupCode("");
showToast({
alertType: 'success',
title: t('account.mfa.enabled', 'Two-factor authentication enabled.'),
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.')
t("account.mfa.enableFailed", "Unable to enable two-factor authentication. Check the code and try again."),
);
} finally {
setMfaLoading(false);
}
},
[mfaSetupCode, t]
[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.'));
setMfaError(t("account.mfa.codeRequired", "Enter the authentication code to continue."));
return;
}
try {
setMfaLoading(true);
setMfaError('');
setMfaError("");
await accountService.disableMfa(mfaDisableCode.trim());
setMfaEnabled(false);
setMfaDisableModalOpen(false);
setMfaDisableCode('');
setMfaDisableCode("");
showToast({
alertType: 'success',
title: t('account.mfa.disabled', 'Two-factor authentication disabled.'),
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.')
t("account.mfa.disableFailed", "Unable to disable two-factor authentication. Check the code and try again."),
);
} finally {
setMfaLoading(false);
}
},
[mfaDisableCode, t]
[mfaDisableCode, t],
);
const handleCloseMfaSetupModal = useCallback(async () => {
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
setMfaError('');
setMfaSetupCode("");
setMfaError("");
try {
await accountService.cancelMfaSetup();
} catch {
console.warn('Failed to clear pending MFA setup');
console.warn("Failed to clear pending MFA setup");
}
}, []);
const handleCloseMfaDisableModal = useCallback(() => {
setMfaDisableModalOpen(false);
setMfaDisableCode('');
setMfaError('');
setMfaDisableCode("");
setMfaError("");
}, []);
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSsoUser) {
setUsernameError(t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.'));
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.'));
setUsernameError(t("settings.security.password.required", "All fields are required."));
return;
}
try {
setUsernameSubmitting(true);
setUsernameError('');
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.'),
alertType: "success",
title: t("changeCreds.credsUpdated", "Account updated"),
body: t("changeCreds.description", "Changes saved. Please log in again."),
});
setNewUsername('');
setCurrentPasswordForUsername('');
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.')
t("changeCreds.error", "Unable to update username. Please verify your password and try again."),
);
} finally {
setUsernameSubmitting(false);
@@ -268,10 +274,10 @@ const AccountSection: React.FC = () => {
<Stack gap="md">
<div>
<Text fw={600} size="lg">
{t('account.accountSettings', 'Account')}
{t("account.accountSettings", "Account")}
</Text>
<Text size="sm" c="dimmed">
{t('changeCreds.header', 'Update Your Account Details')}
{t("changeCreds.header", "Update Your Account Details")}
</Text>
</div>
@@ -279,21 +285,21 @@ const AccountSection: React.FC = () => {
<Stack gap="sm">
<Text size="sm" c="dimmed">
{userIdentifier
? t('settings.general.user', 'User') + ': ' + userIdentifier
: t('account.accountSettings', 'Account Settings')}
? 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.')}
{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')}
{t("settings.security.password.update", "Update password")}
</Button>
)}
@@ -303,12 +309,12 @@ const AccountSection: React.FC = () => {
leftSection={<LocalIcon icon="edit-rounded" />}
onClick={() => setUsernameModalOpen(true)}
>
{t('account.changeUsername', 'Change username')}
{t("account.changeUsername", "Change username")}
</Button>
)}
<Button variant="outline" color="red" leftSection={<LocalIcon icon="logout-rounded" />} onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
{t("settings.general.logout", "Log out")}
</Button>
</Group>
</Stack>
@@ -317,16 +323,13 @@ const AccountSection: React.FC = () => {
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={600}>{t('account.mfa.title', 'Two-factor authentication')}</Text>
<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.')}
{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.'
)}
{t("account.mfa.ssoManaged", "Two-factor authentication for this account is managed by your identity provider.")}
</Alert>
) : (
<Group gap="sm" wrap="wrap">
@@ -337,7 +340,7 @@ const AccountSection: React.FC = () => {
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t('account.mfa.enableButton', 'Enable two-factor authentication')}
{t("account.mfa.enableButton", "Enable two-factor authentication")}
</Button>
) : (
<Button
@@ -345,13 +348,13 @@ const AccountSection: React.FC = () => {
color="red"
leftSection={<LocalIcon icon="close-rounded" />}
onClick={() => {
setMfaError('');
setMfaDisableCode('');
setMfaError("");
setMfaDisableCode("");
setMfaDisableModalOpen(true);
}}
disabled={changeButtonDisabled}
>
{t('account.mfa.disableButton', 'Disable two-factor authentication')}
{t("account.mfa.disableButton", "Disable two-factor authentication")}
</Button>
)}
</Group>
@@ -362,14 +365,14 @@ const AccountSection: React.FC = () => {
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
title={t('settings.security.title', 'Change password')}
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.')}
{t("settings.security.password.subtitle", "Change your password. You will be logged out after updating.")}
</Text>
{passwordError && (
@@ -379,24 +382,24 @@ const AccountSection: React.FC = () => {
)}
<PasswordInput
label={t('settings.security.password.current', 'Current password')}
placeholder={t('settings.security.password.currentPlaceholder', 'Enter your current password')}
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')}
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')}
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
@@ -404,10 +407,10 @@ const AccountSection: React.FC = () => {
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPasswordModalOpen(false)}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={passwordSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('settings.security.password.update', 'Update password')}
{t("settings.security.password.update", "Update password")}
</Button>
</Group>
</Stack>
@@ -417,7 +420,7 @@ const AccountSection: React.FC = () => {
<Modal
opened={mfaSetupModalOpen}
onClose={handleCloseMfaSetupModal}
title={t('account.mfa.setupTitle', 'Set up two-factor authentication')}
title={t("account.mfa.setupTitle", "Set up two-factor authentication")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
@@ -425,22 +428,22 @@ const AccountSection: React.FC = () => {
<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.'
"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)',
padding: "1.5rem",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG
value={mfaSetupData.otpauthUri || ''}
value={mfaSetupData.otpauthUri || ""}
size={180}
level="H"
imageSettings={{
@@ -452,12 +455,12 @@ const AccountSection: React.FC = () => {
/>
</Box>
<Text size="sm" c="dimmed">
{t('account.mfa.manualKey', 'Manual setup key')}: <strong>{mfaSetupData.secret}</strong>
{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.'
"account.mfa.secretWarning",
"Keep this key private. Anyone with access can generate valid authentication codes.",
)}
</Text>
</Stack>
@@ -468,10 +471,12 @@ const AccountSection: React.FC = () => {
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
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))}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
@@ -481,10 +486,10 @@ const AccountSection: React.FC = () => {
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaSetupModal}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={mfaLoading}>
{t('account.mfa.confirmEnable', 'Enable')}
{t("account.mfa.confirmEnable", "Enable")}
</Button>
</Group>
</Stack>
@@ -494,14 +499,14 @@ const AccountSection: React.FC = () => {
<Modal
opened={mfaDisableModalOpen}
onClose={handleCloseMfaDisableModal}
title={t('account.mfa.disableTitle', 'Disable two-factor authentication')}
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.')}
{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">
@@ -509,10 +514,12 @@ const AccountSection: React.FC = () => {
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
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))}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
@@ -522,10 +529,10 @@ const AccountSection: React.FC = () => {
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaDisableModal}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" color="red" loading={mfaLoading}>
{t('account.mfa.confirmDisable', 'Disable')}
{t("account.mfa.confirmDisable", "Disable")}
</Button>
</Group>
</Stack>
@@ -535,14 +542,14 @@ const AccountSection: React.FC = () => {
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
title={t('account.changeUsername', 'Change username')}
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.')}
{t("changeCreds.changeUsername", "Update your username. You will be logged out after updating.")}
</Text>
{usernameError && (
@@ -552,27 +559,29 @@ const AccountSection: React.FC = () => {
)}
<TextInput
label={t('changeCreds.newUsername', 'New Username')}
placeholder={t('changeCreds.newUsername', 'New Username')}
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')}
label={t("changeCreds.oldPassword", "Current Password")}
placeholder={t("changeCreds.oldPassword", "Current Password")}
value={currentPasswordForUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
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')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={usernameSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('common.save', 'Save')}
{t("common.save", "Save")}
</Button>
</Group>
</Stack>
@@ -1,33 +1,33 @@
import React, { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } 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';
import AuditChartsSection from '@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';
import React, { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } 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";
import AuditChartsSection from "@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 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');
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">("week");
useEffect(() => {
const fetchSystemStatus = async () => {
@@ -40,9 +40,9 @@ const AdminAuditSection: React.FC = () => {
// 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');
setError("enterprise-license-required");
} else {
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
setError(err instanceof Error ? err.message : "Failed to load audit system status");
}
} finally {
setLoading(false);
@@ -56,7 +56,7 @@ const AdminAuditSection: React.FC = () => {
setError(null);
setSystemStatus({
enabled: true,
level: 'INFO',
level: "INFO",
retentionDays: 90,
totalEvents: 1234,
pdfMetadataEnabled: true,
@@ -73,25 +73,25 @@ const AdminAuditSection: React.FC = () => {
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<Loader size="lg" />
</div>
);
}
if (error) {
if (error === 'enterprise-license-required') {
if (error === "enterprise-license-required") {
return (
<Alert color="blue" title={t('audit.enterpriseRequired', 'Enterprise License Required')}>
<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.'
"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')}>
<Alert color="red" title={t("audit.error.title", "Error loading audit system")}>
{error}
</Alert>
);
@@ -99,8 +99,8 @@ const AdminAuditSection: React.FC = () => {
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 color="yellow" title={t("audit.notAvailable", "Audit system not available")}>
{t("audit.notAvailableMessage", "The audit system is not configured or not available.")}
</Alert>
);
}
@@ -110,33 +110,30 @@ const AdminAuditSection: React.FC = () => {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t('settings.licensingAnalytics.audit', 'Audit')}
/>
<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')}
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.'
"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')}
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('audit.goToSettings', 'Go to Audit Settings')}
{t("audit.goToSettings", "Go to Audit Settings")}
</Button>
</Stack>
</Alert>
@@ -148,16 +145,16 @@ const AdminAuditSection: React.FC = () => {
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
{t("audit.tabs.dashboard", "Dashboard")}
</Tabs.Tab>
<Tabs.Tab value="events" disabled={!isEnabled}>
{t('audit.tabs.events', 'Audit Events')}
{t("audit.tabs.events", "Audit Events")}
</Tabs.Tab>
<Tabs.Tab value="export" disabled={!isEnabled}>
{t('audit.tabs.export', 'Export')}
{t("audit.tabs.export", "Export")}
</Tabs.Tab>
<Tabs.Tab value="clearData" disabled={!isEnabled}>
{t('audit.tabs.clearData', 'Clear Data')}
{t("audit.tabs.clearData", "Clear Data")}
</Tabs.Tab>
</Tabs.List>
@@ -169,15 +166,9 @@ const AdminAuditSection: React.FC = () => {
{/* 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.Control>{t("audit.charts.overTime", "Events Over Time")}</Accordion.Control>
<Accordion.Panel>
<AuditChartsSection
loginEnabled={isEnabled}
timePeriod={timePeriod}
onTimePeriodChange={setTimePeriod}
/>
<AuditChartsSection loginEnabled={isEnabled} timePeriod={timePeriod} onTimePeriodChange={setTimePeriod} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -206,11 +197,8 @@ const AdminAuditSection: React.FC = () => {
</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 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>
@@ -1,21 +1,21 @@
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';
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;
@@ -140,26 +140,26 @@ export default function AdminConnectionsSection() {
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
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 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 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 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 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 systemResponse = await apiClient.get("/api/v1/admin/settings/section/system");
const systemData = systemResponse.data || {};
const result: ConnectionsSettingsData & { _pending?: Record<string, unknown> } = {
@@ -170,13 +170,13 @@ export default function AdminConnectionsSection() {
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false,
mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false,
mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full',
mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4',
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 || ''
googleDriveClientId: premiumData.proFeatures?.googleDrive?.clientId || "",
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || "",
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || "",
};
// Merge pending blocks from all endpoints
@@ -236,7 +236,7 @@ export default function AdminConnectionsSection() {
// Build delta for oauth2 settings
if (currentSettings.oauth2) {
Object.keys(currentSettings.oauth2).forEach((key) => {
if (key !== 'client') {
if (key !== "client") {
deltaSettings[`security.oauth2.${key}`] = (currentSettings.oauth2 as Record<string, unknown>)[key];
}
});
@@ -279,54 +279,48 @@ export default function AdminConnectionsSection() {
// SSO Auto Login
if (currentSettings?.ssoAutoLogin !== undefined) {
deltaSettings['premium.proFeatures.ssoAutoLogin'] = currentSettings.ssoAutoLogin;
deltaSettings["premium.proFeatures.ssoAutoLogin"] = currentSettings.ssoAutoLogin;
}
// Mobile Scanner settings
if (currentSettings?.enableMobileScanner !== undefined) {
deltaSettings['system.enableMobileScanner'] = currentSettings.enableMobileScanner;
deltaSettings["system.enableMobileScanner"] = currentSettings.enableMobileScanner;
}
if (currentSettings?.mobileScannerConvertToPdf !== undefined) {
deltaSettings['system.mobileScannerSettings.convertToPdf'] = currentSettings.mobileScannerConvertToPdf;
deltaSettings["system.mobileScannerSettings.convertToPdf"] = currentSettings.mobileScannerConvertToPdf;
}
if (currentSettings?.mobileScannerImageResolution !== undefined) {
deltaSettings['system.mobileScannerSettings.imageResolution'] = currentSettings.mobileScannerImageResolution;
deltaSettings["system.mobileScannerSettings.imageResolution"] = currentSettings.mobileScannerImageResolution;
}
if (currentSettings?.mobileScannerPageFormat !== undefined) {
deltaSettings['system.mobileScannerSettings.pageFormat'] = currentSettings.mobileScannerPageFormat;
deltaSettings["system.mobileScannerSettings.pageFormat"] = currentSettings.mobileScannerPageFormat;
}
if (currentSettings?.mobileScannerStretchToFit !== undefined) {
deltaSettings['system.mobileScannerSettings.stretchToFit'] = currentSettings.mobileScannerStretchToFit;
deltaSettings["system.mobileScannerSettings.stretchToFit"] = currentSettings.mobileScannerStretchToFit;
}
// Google Drive settings
if (currentSettings?.googleDriveEnabled !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.enabled'] = currentSettings.googleDriveEnabled;
deltaSettings["premium.proFeatures.googleDrive.enabled"] = currentSettings.googleDriveEnabled;
}
if (currentSettings?.googleDriveClientId !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.clientId'] = currentSettings.googleDriveClientId;
deltaSettings["premium.proFeatures.googleDrive.clientId"] = currentSettings.googleDriveClientId;
}
if (currentSettings?.googleDriveApiKey !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.apiKey'] = currentSettings.googleDriveApiKey;
deltaSettings["premium.proFeatures.googleDrive.apiKey"] = currentSettings.googleDriveApiKey;
}
if (currentSettings?.googleDriveAppId !== undefined) {
deltaSettings['premium.proFeatures.googleDrive.appId'] = currentSettings.googleDriveAppId;
deltaSettings["premium.proFeatures.googleDrive.appId"] = currentSettings.googleDriveAppId;
}
return {
sectionData: {},
deltaSettings
deltaSettings,
};
}
},
});
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = adminSettings;
const { settings, setSettings, loading, fetchSettings, isFieldPending } = adminSettings;
useEffect(() => {
if (loginEnabled) {
@@ -347,9 +341,9 @@ export default function AdminConnectionsSection() {
await adminSettings.saveSettings();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -358,45 +352,45 @@ export default function AdminConnectionsSection() {
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
if (provider.id === "saml2") {
return settings?.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
if (provider.id === "smtp") {
return settings?.mail?.enabled === true;
}
if (provider.id === 'telegram') {
if (provider.id === "telegram") {
return settings?.telegram?.enabled === true;
}
if (provider.id === 'googledrive') {
if (provider.id === "googledrive") {
return settings?.googleDriveEnabled === true;
}
if (provider.id === 'oauth2-generic') {
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);
return !!providerSettings?.clientId;
};
const getProviderSettings = (provider: Provider): ProviderSettings => {
if (provider.id === 'saml2') {
if (provider.id === "saml2") {
return settings?.saml2 || {};
}
if (provider.id === 'smtp') {
if (provider.id === "smtp") {
return settings?.mail || {};
}
if (provider.id === 'telegram') {
if (provider.id === "telegram") {
return settings?.telegram || {};
}
if (provider.id === 'googledrive') {
if (provider.id === "googledrive") {
const gd: GoogleDriveSettings = {
enabled: settings?.googleDriveEnabled,
clientId: settings?.googleDriveClientId,
@@ -406,7 +400,7 @@ export default function AdminConnectionsSection() {
return gd;
}
if (provider.id === 'oauth2-generic') {
if (provider.id === "oauth2-generic") {
const generic: OAuth2GenericSettings = {
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
@@ -425,7 +419,6 @@ export default function AdminConnectionsSection() {
return settings?.oauth2?.client?.[provider.id] || {};
};
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
@@ -434,16 +427,15 @@ export default function AdminConnectionsSection() {
);
}
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') {
if (provider.id === "smtp") {
setSettings({ ...settings, mail: updatedSettings as MailSettings });
} else if (provider.id === 'telegram') {
} else if (provider.id === "telegram") {
setSettings({ ...settings, telegram: updatedSettings as TelegramSettingsData });
} else if (provider.id === 'googledrive') {
} else if (provider.id === "googledrive") {
const gd = updatedSettings as GoogleDriveSettings;
setSettings({
...settings,
@@ -452,9 +444,9 @@ export default function AdminConnectionsSection() {
googleDriveApiKey: gd.apiKey,
googleDriveAppId: gd.appId,
});
} else if (provider.id === 'saml2') {
} else if (provider.id === "saml2") {
setSettings({ ...settings, saml2: updatedSettings as Saml2Settings });
} else if (provider.id === 'oauth2-generic') {
} else if (provider.id === "oauth2-generic") {
const generic = updatedSettings as OAuth2GenericSettings;
setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } });
} else {
@@ -467,8 +459,8 @@ export default function AdminConnectionsSection() {
client: {
...settings.oauth2?.client,
[provider.id]: clientSettings,
}
}
},
},
});
}
};
@@ -478,225 +470,290 @@ export default function AdminConnectionsSection() {
<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>
{/* 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')}
{/* 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>
</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')} />
<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>
</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 style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.connections.mobileScannerConvertToPdf', 'Convert Images to PDF')}
<Text fw={500} size="sm">
{t("admin.settings.connections.ssoAutoLogin.enable", "Enable SSO Auto Login")}
</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 size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.ssoAutoLogin.description",
"Automatically redirect to SSO login when authentication is required",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
</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}
/>
<PendingBadge show={isFieldPending('mobileScannerConvertToPdf')} />
</Group>
</div>
))}
</Stack>
</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>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* 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 && (
<>
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.linkedServices', 'Linked Services')}
{t("admin.settings.connections.unlinkedServices", "Unlinked Services")}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
disabled={!loginEnabled}
@@ -704,39 +761,10 @@ export default function AdminConnectionsSection() {
))}
</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}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</Stack>
<SettingsStickyFooter
@@ -143,7 +143,7 @@ export default function AdminDatabaseSection() {
setBackupFiles(data.backupFiles || []);
setDatabaseVersion(data.databaseVersion || null);
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.loadError", "Failed to load database backups"),
@@ -191,7 +191,7 @@ export default function AdminDatabaseSection() {
alert({ alertType: "success", title: t("admin.settings.database.backupCreated", "Backup created successfully") });
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.backupFailed", "Failed to create backup"),
@@ -211,7 +211,7 @@ export default function AdminDatabaseSection() {
setUploadFile(null);
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
@@ -272,7 +272,7 @@ export default function AdminDatabaseSection() {
alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") });
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
@@ -291,7 +291,7 @@ export default function AdminDatabaseSection() {
alert({ alertType: "success", title: t("admin.settings.database.deleteSuccess", "Backup deleted") });
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.deleteFailed", "Failed to delete backup"),
@@ -322,7 +322,7 @@ export default function AdminDatabaseSection() {
document.body.appendChild(link);
link.click();
} catch (error: unknown) {
const message = isAxiosError(error) ? (error.response?.data?.message || error.message) : undefined;
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
alert({
alertType: "error",
title: t("admin.settings.database.downloadFailed", "Failed to download backup"),
@@ -355,195 +355,196 @@ export default function AdminDatabaseSection() {
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{t("admin.settings.database.title", "Database")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.description",
"Configure custom database connection settings for enterprise deployments.",
)}
</Text>
</div>
<Badge color="grape" size="lg">
ENTERPRISE
</Badge>
</Group>
</div>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
<Text fw={600} size="lg">
{t("admin.settings.database.title", "Database")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.enableCustom.description",
"Use your own custom database configuration instead of the default embedded database",
"admin.settings.database.description",
"Configure custom database connection settings for enterprise deployments.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
</Group>
</div>
<Badge color="grape" size="lg">
ENTERPRISE
</Badge>
</Group>
</div>
{settings?.enableCustomDatabase && (
<>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
</Group>
}
description={t(
"admin.settings.database.customUrl.description",
"Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.database.enableCustom.description",
"Use your own custom database configuration instead of the default embedded database",
)}
value={settings?.customDatabaseUrl || ""}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</Text>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
description={t(
"admin.settings.database.type.description",
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
data={[
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
{ value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
]}
<Group gap="xs">
<Switch
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
</div>
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
description={t(
"admin.settings.database.hostName.description",
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
{settings?.enableCustomDatabase && (
<>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
</Group>
}
description={t(
"admin.settings.database.customUrl.description",
"Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
)}
value={settings?.customDatabaseUrl || ""}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.port.label", "Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.database.port.description",
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
description={t(
"admin.settings.database.type.description",
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
data={[
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
{ value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
]}
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
description={t(
"admin.settings.database.name.description",
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
description={t(
"admin.settings.database.hostName.description",
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.username.label", "Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.database.username.description", "Database authentication username")}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.port.label", "Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.database.port.description",
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>{t("admin.settings.database.password.label", "Password")}</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
</div>
</>
)}
</Stack>
</Paper>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
description={t(
"admin.settings.database.name.description",
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.username.label", "Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.database.username.description", "Database authentication username")}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
{t("admin.settings.database.password.label", "Password")}
</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
</div>
</>
)}
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -555,262 +556,260 @@ export default function AdminDatabaseSection() {
/>
<Stack gap="lg" className="settings-section-content" style={{ marginTop: 0 }}>
<Divider my="md" />
<Divider my="md" />
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{t("admin.settings.database.backupTitle", "Backups & Restore")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.database.backupDescription", "Manage H2 backups directly from the admin console.")}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{t("admin.settings.database.backupTitle", "Backups & Restore")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.database.backupDescription", "Manage H2 backups directly from the admin console.")}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
{isEmbeddedH2
? t("admin.settings.database.embedded", "Embedded H2")
: t("admin.settings.database.external", "External DB")}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
{isEmbeddedH2
? t("admin.settings.database.embedded", "Embedded H2")
: t("admin.settings.database.external", "External DB")}
</Badge>
</Group>
</Group>
</Group>
{!isEmbeddedH2 && (
<Alert icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />} color="yellow" radius="md">
<Text fw={600} size="sm">
{t("admin.settings.database.h2Only", "Backups are available only for the embedded H2 database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.h2Hint",
"Set the database type to H2 and disable custom database to enable backup and restore.",
)}
</Text>
</Alert>
)}
{isEmbeddedH2 && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs">
<LocalIcon icon="backup" width="1.4rem" height="1.4rem" />
<Text fw={600}>{t("admin.settings.database.manageBackups", "Manage backups")}</Text>
{!isEmbeddedH2 && (
<Alert icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />} color="yellow" radius="md">
<Text fw={600} size="sm">
{t("admin.settings.database.h2Only", "Backups are available only for the embedded H2 database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.h2Hint",
"Set the database type to H2 and disable custom database to enable backup and restore.",
)}
</Text>
</Alert>
)}
{isEmbeddedH2 && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs">
<LocalIcon icon="backup" width="1.4rem" height="1.4rem" />
<Text fw={600}>{t("admin.settings.database.manageBackups", "Manage backups")}</Text>
</Group>
<Group gap="xs">
<Button
variant="light"
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={loadBackupData}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.refresh", "Refresh")}
</Button>
<Button
leftSection={<LocalIcon icon="cloud-upload" width="1rem" height="1rem" />}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.createBackup", "Create backup")}
</Button>
</Group>
</Group>
<Group gap="xs">
<Button
variant="light"
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={loadBackupData}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.refresh", "Refresh")}
</Button>
<Button
leftSection={<LocalIcon icon="cloud-upload" width="1rem" height="1rem" />}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.createBackup", "Create backup")}
</Button>
</Group>
</Group>
<Box>
<Text fw={500} size="sm" mb={6}>
{t("admin.settings.database.uploadTitle", "Upload & import")}
</Text>
<Group gap="sm" align="flex-end" wrap="wrap">
<FileInput
value={uploadFile}
onChange={setUploadFile}
placeholder={t("admin.settings.database.chooseFile", "Choose a .sql backup file")}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
/>
<Button
variant="outline"
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
</Button>
</Group>
</Box>
<Box>
<Text fw={500} size="sm" mb={6}>
{t("admin.settings.database.uploadTitle", "Upload & import")}
</Text>
<Group gap="sm" align="flex-end" wrap="wrap">
<FileInput
value={uploadFile}
onChange={setUploadFile}
placeholder={t("admin.settings.database.chooseFile", "Choose a .sql backup file")}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
/>
<Button
variant="outline"
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
</Button>
</Group>
</Box>
{backupsLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? t("admin.settings.database.noBackups", "No backups found yet.")
: t(
"admin.settings.database.unavailable",
"Backup list unavailable for the current database configuration.",
)}
</Text>
) : (
<Table highlightOnHover withColumnBorders verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("admin.settings.database.fileName", "File")}</Table.Th>
<Table.Th>{t("admin.settings.database.created", "Created")}</Table.Th>
<Table.Th>{t("admin.settings.database.size", "Size")}</Table.Th>
<Table.Th w={150}>{t("admin.settings.database.actions", "Actions")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{backupFiles.map((backup) => (
<Table.Tr key={backup.fileName}>
<Table.Td>{backup.fileName}</Table.Td>
<Table.Td>{backup.formattedCreationDate || backup.creationDate || "-"}</Table.Td>
<Table.Td>{backup.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
{backupsLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? t("admin.settings.database.noBackups", "No backups found yet.")
: t(
"admin.settings.database.unavailable",
"Backup list unavailable for the current database configuration.",
)}
</Text>
) : (
<Table highlightOnHover withColumnBorders verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("admin.settings.database.fileName", "File")}</Table.Th>
<Table.Th>{t("admin.settings.database.created", "Created")}</Table.Th>
<Table.Th>{t("admin.settings.database.size", "Size")}</Table.Th>
<Table.Th w={150}>{t("admin.settings.database.actions", "Actions")}</Table.Th>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Thead>
<Table.Tbody>
{backupFiles.map((backup) => (
<Table.Tr key={backup.fileName}>
<Table.Td>{backup.fileName}</Table.Td>
<Table.Td>{backup.formattedCreationDate || backup.creationDate || "-"}</Table.Td>
<Table.Td>{backup.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
)}
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
title={t("admin.settings.database.confirmImportTitle", "Confirm database import")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.overwriteWarningBody",
"All existing data will be replaced by the uploaded backup. This action cannot be undone.",
)}
</Text>
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
</Text>
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
</Stack>
</Paper>
)}
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
title={t("admin.settings.database.confirmImportTitle", "Confirm database import")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.overwriteWarningBody",
"All existing data will be replaced by the uploaded backup. This action cannot be undone.",
)}
</Text>
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
</Text>
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
</Stack>
</Modal>
</Modal>
<Modal
opened={deleteConfirmFile !== null}
onClose={() => setDeleteConfirmFile(null)}
title={t("admin.settings.database.deleteTitle", "Delete backup")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>
{t("admin.settings.database.deleteConfirm", "Delete this backup? This cannot be undone.")}
</Text>
<Text size="sm" c="dimmed">
{deleteConfirmFile}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteConfirmFile(null)} disabled={deletingFile !== null}>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteConfirmFile !== null}
onClose={() => setDeleteConfirmFile(null)}
title={t("admin.settings.database.deleteTitle", "Delete backup")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>{t("admin.settings.database.deleteConfirm", "Delete this backup? This cannot be undone.")}</Text>
<Text size="sm" c="dimmed">
{deleteConfirmFile}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteConfirmFile(null)} disabled={deletingFile !== null}>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</div>
);
@@ -1,15 +1,15 @@
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';
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;
@@ -26,17 +26,10 @@ export default function AdminEndpointsSection() {
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: 'endpoints',
});
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<EndpointsSettingsData>({
sectionName: "endpoints",
});
const {
settings: uiSettings,
@@ -47,7 +40,7 @@ export default function AdminEndpointsSection() {
saveSettings: saveUiSettings,
isFieldPending: isUiFieldPending,
} = useAdminSettings<UISettingsData>({
sectionName: 'ui',
sectionName: "ui",
});
useEffect(() => {
@@ -57,8 +50,16 @@ export default function AdminEndpointsSection() {
}
}, [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,
resetToSnapshot: resetEndpointsSnapshot,
markSaved: markEndpointsSaved,
} = useSettingsDirty(settings, loading);
const {
isDirty: isUiDirty,
resetToSnapshot: resetUiSnapshot,
markSaved: markUiSaved,
} = useSettingsDirty(uiSettings, uiLoading);
const isDirty = isEndpointsDirty || isUiDirty;
@@ -79,9 +80,9 @@ export default function AdminEndpointsSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -98,7 +99,7 @@ export default function AdminEndpointsSection() {
}, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? (loading || uiLoading) : false;
const actualLoading = loginEnabled ? loading || uiLoading : false;
if (actualLoading) {
return (
@@ -110,111 +111,111 @@ export default function AdminEndpointsSection() {
// 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',
"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',
"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',
"CLI",
"Python",
"OpenCV",
"LibreOffice",
"Unoconvert",
"Java",
"Javascript",
"qpdf",
"Ghostscript",
"ImageMagick",
"tesseract",
"OCRmyPDF",
"Weasyprint",
"Pdftohtml",
"Calibre",
"FFmpeg",
"veraPDF",
"rar",
];
return (
@@ -222,107 +223,129 @@ export default function AdminEndpointsSection() {
<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>
<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.')}
<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>
<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}
/>
<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>
<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>
<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
@@ -334,11 +357,7 @@ export default function AdminEndpointsSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +1,17 @@
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';
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?: {
@@ -28,52 +28,45 @@ export default function AdminFeaturesSection() {
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 { 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
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 };
}
};
// 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> = {};
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;
}
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
};
}
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -93,9 +86,9 @@ export default function AdminFeaturesSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -119,121 +112,148 @@ export default function AdminFeaturesSection() {
<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')}
<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>
<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')}
{/* 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>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
<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, enabled: e.target.checked }
});
}}
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value },
})
}
placeholder="Stirling-PDF"
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;
<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, regenerateOnStartup: e.target.checked }
});
}}
serverCertificate: { ...settings.serverCertificate, validity: Number(value) },
})
}
min={1}
max={3650}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
</div>
</Stack>
</Paper>
</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
@@ -245,11 +265,7 @@ export default function AdminFeaturesSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,16 +1,16 @@
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';
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;
@@ -25,17 +25,10 @@ export default function AdminLegalSection() {
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: 'legal',
});
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<LegalSettingsData>({
sectionName: "legal",
});
useEffect(() => {
if (loginEnabled) {
@@ -43,7 +36,6 @@ export default function AdminLegalSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -55,9 +47,9 @@ export default function AdminLegalSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -80,112 +72,123 @@ export default function AdminLegalSection() {
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>
<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>
{/* 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>
<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.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.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.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>
<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
@@ -197,11 +200,7 @@ export default function AdminLegalSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +1,17 @@
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';
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;
@@ -36,27 +36,20 @@ export default function AdminMailSection() {
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: {}
};
}
});
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();
@@ -71,9 +64,9 @@ export default function AdminMailSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -95,127 +88,149 @@ export default function AdminMailSection() {
<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>
<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>
<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 })}
<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"
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</Group>
</div>
<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}
<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}
/>
<PendingBadge show={isFieldPending('enableInvites')} />
</Group>
</Group>
</div>
<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>
<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>
<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>
<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.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>
<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
@@ -227,11 +242,7 @@ export default function AdminMailSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,20 +1,20 @@
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';
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();
@@ -39,13 +39,13 @@ const AdminPlanSection: React.FC = () => {
}, [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)' },
{ 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 () => {
@@ -56,28 +56,25 @@ const AdminPlanSection: React.FC = () => {
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?.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.');
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
);
const response = await licenseService.createBillingPortalSession(window.location.href, licenseInfo.licenseKey);
// Open billing portal in new tab
window.open(response.url, '_blank');
window.open(response.url, "_blank");
} catch (error: unknown) {
console.error('Failed to open billing portal:', error);
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.',
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]);
@@ -96,19 +93,19 @@ const AdminPlanSection: React.FC = () => {
}
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === 'free') {
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') {
if (currentTier === "free" && planGroup.tier === "enterprise") {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
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.'
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
@@ -124,13 +121,13 @@ const AdminPlanSection: React.FC = () => {
},
});
},
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled]
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled],
);
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === "admin";
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t('plan.licenseWarning.overLimit', 'more than {{limit}}', {
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
limit: licenseAlert.freeTierLimit,
});
}
@@ -138,9 +135,9 @@ const AdminPlanSection: React.FC = () => {
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById('available-plans-section');
const el = document.getElementById("available-plans-section");
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
@@ -152,7 +149,7 @@ const AdminPlanSection: React.FC = () => {
// Early returns after all hooks are called
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<Loader size="lg" />
</div>
);
@@ -172,19 +169,19 @@ const AdminPlanSection: React.FC = () => {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<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', {
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')}
buttonText={t("plan.licenseWarning.cta", "See plans")}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
@@ -1,16 +1,16 @@
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';
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;
@@ -22,17 +22,10 @@ export default function AdminPremiumSection() {
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<PremiumSettingsData>({
sectionName: "premium",
});
useEffect(() => {
if (loginEnabled) {
@@ -40,7 +33,6 @@ export default function AdminPremiumSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -52,9 +44,9 @@ export default function AdminPremiumSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -77,75 +69,100 @@ export default function AdminPremiumSection() {
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>
<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')}
{/* 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>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
</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}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</div>
</Stack>
</Paper>
</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
@@ -157,11 +174,7 @@ export default function AdminPremiumSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,16 +1,16 @@
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';
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;
@@ -23,62 +23,55 @@ export default function AdminPrivacySection() {
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 { 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 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
};
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;
}
// 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;
}
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 result;
},
saveTransformer: (settings: PrivacySettingsData) => {
const deltaSettings = {
"system.enableAnalytics": settings.enableAnalytics,
"system.googlevisibility": settings.googleVisibility,
"metrics.enabled": settings.metricsEnabled,
};
return {
sectionData: {},
deltaSettings
};
}
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -103,9 +96,9 @@ export default function AdminPrivacySection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -124,92 +117,109 @@ export default function AdminPrivacySection() {
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<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>
<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>
{/* 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 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>
<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 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>
<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>
</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>
{/* 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 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>
<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>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -221,11 +231,7 @@ export default function AdminPrivacySection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +1,17 @@
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';
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;
@@ -37,47 +37,40 @@ export default function AdminStorageSharingSection() {
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 { 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 || {};
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,
},
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) {
@@ -106,9 +99,9 @@ export default function AdminStorageSharingSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -123,190 +116,203 @@ export default function AdminStorageSharingSection() {
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 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>
<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}
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -1,59 +1,59 @@
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';
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 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 [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 },
{ 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') {
if (displayMode === "top10") {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === 'top20') {
} else if (displayMode === "top20") {
filteredEndpoints = allEndpoints.slice(0, 20);
}
@@ -69,12 +69,12 @@ const AdminUsageSection: React.FC = () => {
setLoading(true);
setError(null);
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
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');
setError(err instanceof Error ? err.message : "Failed to load usage statistics");
} finally {
setLoading(false);
}
@@ -103,14 +103,14 @@ const AdminUsageSection: React.FC = () => {
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');
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 '';
return "";
}
};
@@ -120,7 +120,7 @@ const AdminUsageSection: React.FC = () => {
// Early returns for loading/error states
if (actualLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
<Loader size="lg" />
</div>
);
@@ -128,7 +128,7 @@ const AdminUsageSection: React.FC = () => {
if (error) {
return (
<Alert color="red" title={t('usage.error', 'Error loading usage statistics')}>
<Alert color="red" title={t("usage.error", "Error loading usage statistics")}>
{error}
</Alert>
);
@@ -136,14 +136,14 @@ const AdminUsageSection: React.FC = () => {
if (!data) {
return (
<Alert color="yellow" title={t('usage.noData', 'No data available')}>
{t('usage.noDataMessage', 'No usage statistics are currently available.')}
<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'),
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,
}));
@@ -154,56 +154,50 @@ const AdminUsageSection: React.FC = () => {
}));
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 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';
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')}
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')}
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.'
"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')}
onClick={() => navigate("/settings/adminSecurity")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.configureSettings', 'Configure Analytics Settings')}
{t("usage.configureSettings", "Configure Analytics Settings")}
</Button>
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.viewAuditLogs', 'View Audit Logs')}
{t("usage.viewAuditLogs", "View Audit Logs")}
</Button>
</Group>
</Stack>
@@ -217,20 +211,20 @@ const AdminUsageSection: React.FC = () => {
<Group>
<SegmentedControl
value={displayMode}
onChange={(value) => setDisplayMode(value as 'top10' | 'top20' | 'all')}
onChange={(value) => setDisplayMode(value as "top10" | "top20" | "all")}
disabled={showDemoData}
data={[
{
value: 'top10',
label: t('usage.controls.top10', 'Top 10'),
value: "top10",
label: t("usage.controls.top10", "Top 10"),
},
{
value: 'top20',
label: t('usage.controls.top20', 'Top 20'),
value: "top20",
label: t("usage.controls.top20", "Top 20"),
},
{
value: 'all',
label: t('usage.controls.all', 'All'),
value: "all",
label: t("usage.controls.all", "All"),
},
]}
/>
@@ -241,41 +235,41 @@ const AdminUsageSection: React.FC = () => {
loading={loading}
disabled={showDemoData}
>
{t('usage.controls.refresh', 'Refresh')}
{t("usage.controls.refresh", "Refresh")}
</Button>
</Group>
</Group>
<Group>
<Text size="sm" fw={500}>
{t('usage.controls.dataTypeLabel', 'Data Type:')}
{t("usage.controls.dataTypeLabel", "Data Type:")}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
onChange={(value) => setDataType(value as "all" | "api" | "ui")}
disabled={showDemoData}
data={[
{
value: 'all',
label: t('usage.controls.dataType.all', 'All'),
value: "all",
label: t("usage.controls.dataType.all", "All"),
},
{
value: 'api',
label: t('usage.controls.dataType.api', 'API'),
value: "api",
label: t("usage.controls.dataType.api", "API"),
},
{
value: 'ui',
label: t('usage.controls.dataType.ui', 'UI'),
value: "ui",
label: t("usage.controls.dataType.ui", "UI"),
},
]}
/>
</Group>
{/* Statistics Summary */}
<Group gap="xl" style={{ flexWrap: 'wrap' }}>
<Group gap="xl" style={{ flexWrap: "wrap" }}>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
{t("usage.stats.totalEndpoints", "Total Endpoints")}
</Text>
<Text size="lg" fw={600}>
{totalEndpoints}
@@ -283,7 +277,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.totalVisits', 'Total Visits')}
{t("usage.stats.totalVisits", "Total Visits")}
</Text>
<Text size="lg" fw={600}>
{totalVisits.toLocaleString()}
@@ -291,7 +285,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.showing', 'Showing')}
{t("usage.stats.showing", "Showing")}
</Text>
<Text size="lg" fw={600}>
{getDisplayModeLabel()}
@@ -299,7 +293,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.selectedVisits', 'Selected Visits')}
{t("usage.stats.selectedVisits", "Selected Visits")}
</Text>
<Text size="lg" fw={600}>
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
@@ -25,14 +25,14 @@ export default function ApiKeys() {
setTimeout(() => setCopied(null), 1600);
} else {
// Fallback for HTTP: use old execCommand method
const textarea = document.createElement('textarea');
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
if (document.execCommand('copy')) {
if (document.execCommand("copy")) {
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
}
@@ -40,7 +40,7 @@ export default function ApiKeys() {
document.body.removeChild(textarea);
}
} catch (e) {
console.error('Failed to copy:', e);
console.error("Failed to copy:", e);
}
};
@@ -55,7 +55,7 @@ export default function ApiKeys() {
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.')}
{t("config.apiKeys.intro", "Use your API key to programmatically access Stirling PDF's processing capabilities.")}
</Text>
<Paper
@@ -63,17 +63,17 @@ export default function ApiKeys() {
radius="md"
style={{
background: "var(--bg-muted)",
border: "1px solid var(--border-subtle)"
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')}
{t("config.apiKeys.docsTitle", "API Documentation")}
</Text>
<Text size="sm" c="dimmed">
{t('config.apiKeys.docsDescription', 'Learn more about integrating with Stirling PDF:')}
{t("config.apiKeys.docsDescription", "Learn more about integrating with Stirling PDF:")}
</Text>
<Stack gap={4}>
<Text size="sm">
@@ -81,9 +81,9 @@ export default function ApiKeys() {
href="https://docs.stirlingpdf.com/API"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
{t('config.apiKeys.docsLink', 'API Documentation')}
{t("config.apiKeys.docsLink", "API Documentation")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
@@ -92,9 +92,9 @@ export default function ApiKeys() {
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
{t('config.apiKeys.schemaLink', 'API Schema Reference')}
{t("config.apiKeys.schemaLink", "API Schema Reference")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
@@ -105,15 +105,23 @@ export default function ApiKeys() {
{apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
{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')}
{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)" }}>
<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} />
@@ -131,14 +139,10 @@ export default function ApiKeys() {
)}
<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.')}
{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}
/>
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
</Stack>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -19,21 +19,21 @@ import {
Avatar,
Box,
type ComboboxItem,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService, User } 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 InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
import { useAuth } from '@app/auth/UseSession';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService, User } 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 InviteMembersModal from "@app/components/shared/InviteMembersModal";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useNavigate } from "react-router-dom";
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import { useLicense } from "@app/contexts/LicenseContext";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import { useAuth } from "@app/auth/UseSession";
export default function PeopleSection() {
const { t } = useTranslation();
@@ -45,7 +45,7 @@ export default function PeopleSection() {
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
@@ -70,16 +70,16 @@ export default function PeopleSection() {
return;
}
if (hasNoSlots) {
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
return;
}
setInviteModalOpened(true);
};
const addMemberTooltip = !loginEnabled
? t('workspace.people.loginRequired', 'Enable login mode first')
? t("workspace.people.loginRequired", "Enable login mode first")
: hasNoSlots
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
? t("workspace.people.license.noSlotsAvailable", "No user slots available")
: null;
const isCurrentUser = (user: User) => currentUser?.username === user.username;
@@ -87,7 +87,7 @@ export default function PeopleSection() {
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
@@ -97,7 +97,7 @@ export default function PeopleSection() {
useEffect(() => {
if (config) {
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
console.log("[PeopleSection] Email invites enabled:", config.enableEmailInvites);
}
}, [config]);
@@ -106,17 +106,14 @@ export default function PeopleSection() {
setLoading(true);
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
const [adminData, teamsData] = await Promise.all([userManagementService.getUsers(), teamService.getTeams()]);
// Enrich users with session data
const enrichedUsers = adminData.users.map(user => ({
const enrichedUsers = adminData.users.map((user) => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled: (adminData.userSettings?.[user.username] as Record<string, unknown> | undefined)?.mfaEnabled === 'true',
mfaEnabled: (adminData.userSettings?.[user.username] as Record<string, unknown> | undefined)?.mfaEnabled === "true",
}));
setUsers(enrichedUsers);
@@ -138,57 +135,57 @@ export default function PeopleSection() {
const exampleUsers: User[] = [
{
id: 1,
username: 'admin',
email: '[email protected]',
username: "admin",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_ADMIN',
rolesAsString: 'ROLE_ADMIN',
authenticationType: 'password',
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
},
{
id: 2,
username: 'john.doe',
email: '[email protected]',
username: "john.doe",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
},
{
id: 3,
username: 'jane.smith',
email: '[email protected]',
username: "jane.smith",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'oauth',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "oauth",
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: 'Marketing' }
team: { id: 2, name: "Marketing" },
},
{
id: 4,
username: 'bob.wilson',
email: '[email protected]',
username: "bob.wilson",
email: "[email protected]",
enabled: false,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined
}
team: undefined,
},
];
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 }
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
];
setUsers(exampleUsers);
@@ -207,8 +204,8 @@ export default function PeopleSection() {
});
}
} catch (error) {
console.error('[PeopleSection] Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
console.error("[PeopleSection] Failed to fetch people data:", error);
alert({ alertType: "error", title: "Failed to load people data" });
} finally {
setLoading(false);
}
@@ -224,15 +221,15 @@ export default function PeopleSection() {
role: editForm.role,
teamId: editForm.teamId,
});
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
alert({ alertType: "success", title: t("workspace.people.editMember.success") });
closeEditModal();
fetchData();
} catch (error: unknown) {
console.error('[PeopleSection] Failed to update user:', error);
console.error("[PeopleSection] Failed to update 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.editMember.error');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) || t("workspace.people.editMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -241,53 +238,57 @@ export default function PeopleSection() {
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
alert({ alertType: "success", title: t("workspace.people.toggleEnabled.success") });
fetchData();
} catch (error: unknown) {
console.error('[PeopleSection] Failed to toggle user status:', error);
console.error("[PeopleSection] Failed to toggle user status:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.toggleEnabled.error');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) || t("workspace.people.toggleEnabled.error");
alert({ alertType: "error", title: errorMessage });
}
};
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.');
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 {
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
fetchData();
} catch (error: unknown) {
console.error('[PeopleSection] Failed to delete user:', error);
console.error("[PeopleSection] Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
? 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 });
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
}
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmUnlock', 'Are you sure you want to unlock this user account?');
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') });
alert({ alertType: "success", title: t("workspace.people.unlockUserSuccess", "User account unlocked successfully") });
fetchData();
} catch (error: unknown) {
console.error('[PeopleSection] Failed to unlock user:', error);
console.error("[PeopleSection] 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 });
? 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 });
}
};
@@ -314,36 +315,42 @@ export default function PeopleSection() {
setEditUserModalOpened(false);
setSelectedUser(null);
setEditForm({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredUsers = users.filter((user) => user.username.toLowerCase().includes(searchQuery.toLowerCase()));
const roleOptions = [
{
value: 'ROLE_ADMIN',
label: t('workspace.people.admin'),
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
icon: 'admin-panel-settings'
value: "ROLE_ADMIN",
label: t("workspace.people.admin"),
description: t(
"workspace.people.roleDescriptions.admin",
"Can manage settings and invite members, with full administrative access.",
),
icon: "admin-panel-settings",
},
{
value: 'ROLE_USER',
label: t('workspace.people.member'),
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
icon: 'person'
value: "ROLE_USER",
label: t("workspace.people.member"),
description: t(
"workspace.people.roleDescriptions.member",
"Can view and edit shared files, but cannot manage workspace settings or members.",
),
icon: "person",
},
];
const renderRoleOption = ({ option }: { option: ComboboxItem & { icon?: string; description?: string } }) => (
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon ?? ''} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<LocalIcon icon={option.icon ?? ""} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>{option.label}</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
<Text size="sm" fw={500}>
{option.label}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "normal", lineHeight: 1.4 }}>
{option.description}
</Text>
</Box>
@@ -360,7 +367,7 @@ export default function PeopleSection() {
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.people.loading', 'Loading people...')}
{t("workspace.people.loading", "Loading people...")}
</Text>
</Stack>
);
@@ -371,34 +378,40 @@ export default function PeopleSection() {
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
{t("workspace.people.title")}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.people.description')}
{t("workspace.people.description")}
</Text>
</div>
{/* License Information - Compact */}
{licenseInfo && (
<Group gap="md" style={{ fontSize: '0.875rem' }}>
<Group gap="md" style={{ fontSize: "0.875rem" }}>
<Text size="sm" span c="dimmed">
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
<Text component="span" c="dimmed"> / </Text>
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
<Text component="span" c="dimmed"> {t('workspace.people.license.users', 'users')}</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.totalUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
/{" "}
</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.maxAllowedUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
{t("workspace.people.license.users", "users")}
</Text>
</Text>
{licenseInfo.availableSlots === 0 && (
<Group gap="xs" wrap="nowrap" align="center">
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
{t("workspace.people.license.noSlotsAvailable", "No slots available")}
</Badge>
<Button
size="compact-sm"
variant="outline"
onClick={() => navigate('/settings/adminPlan')}
>
{t('workspace.people.actions.upgrade', 'Upgrade')}
<Button size="compact-sm" variant="outline" onClick={() => navigate("/settings/adminPlan")}>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
</Group>
)}
@@ -407,31 +420,32 @@ export default function PeopleSection() {
<Text size="sm" c="dimmed" span>
<Text component="span" ml={4}>
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
{t("workspace.people.license.grandfatheredShort", "{{count}} grandfathered", {
count: licenseInfo.grandfatheredUserCount,
})}
</Text>
</Text>
)}
{lockedUsers.length > 0 && (
<Badge color="orange" variant="light" size="sm">
{lockedUsers.length} {t('workspace.people.locked', 'locked')}
{lockedUsers.length} {t("workspace.people.locked", "locked")}
</Badge>
)}
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
<Badge color="blue" variant="light" size="sm">
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
+{licenseInfo.licenseMaxUsers} {t("workspace.people.license.fromLicense", "from license")}
</Badge>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
{globalLicenseInfo?.licenseType === "ENTERPRISE" && (
<>
<Text size="sm" c="dimmed" span></Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
<Text size="sm" c="dimmed" span>
</Text>
<UpdateSeatsButton size="xs" onSuccess={fetchData} />
</>
)}
</Group>
@@ -440,7 +454,7 @@ export default function PeopleSection() {
{/* Header Actions */}
<Group justify="space-between">
<TextInput
placeholder={t('workspace.people.searchMembers')}
placeholder={t("workspace.people.searchMembers")}
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
@@ -457,7 +471,7 @@ export default function PeopleSection() {
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
{t("workspace.people.addMembers")}
</Button>
</Tooltip>
</Group>
@@ -467,20 +481,22 @@ export default function PeopleSection() {
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
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.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 style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w={100}>
{t("workspace.people.role")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('workspace.people.team')}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("workspace.people.team")}
</Table.Th>
<Table.Th w={50}></Table.Th>
</Table.Tr>
@@ -490,7 +506,7 @@ export default function PeopleSection() {
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.people.noMembersFound')}
{t("workspace.people.noMembersFound")}
</Text>
</Table.Td>
</Table.Tr>
@@ -498,28 +514,28 @@ export default function PeopleSection() {
filteredUsers.map((user) => (
<Table.Tr
key={user.id}
style={isCurrentUser(user) ? { backgroundColor: 'rgba(34, 139, 230, 0.08)' } : undefined}
style={isCurrentUser(user) ? { backgroundColor: "rgba(34, 139, 230, 0.08)" } : undefined}
>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
? t("workspace.people.disabled", "Disabled")
: user.isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
? 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'}
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
border: user.isActive ? "2px solid var(--mantine-color-green-6)" : "none",
opacity: user.enabled ? 1 : 0.5,
}
},
}}
>
{user.username.charAt(0).toUpperCase()}
@@ -527,7 +543,11 @@ export default function PeopleSection() {
</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}>
<Tooltip
label={user.username}
disabled={user.username.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
@@ -535,9 +555,9 @@ export default function PeopleSection() {
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
@@ -545,7 +565,7 @@ export default function PeopleSection() {
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t('workspace.people.lockedBadge', 'Locked')}
{t("workspace.people.lockedBadge", "Locked")}
</Badge>
)}
</Group>
@@ -558,14 +578,10 @@ export default function PeopleSection() {
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
variant="light"
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'cyan'}
>
{(user.rolesAsString || '').includes('ROLE_ADMIN')
? t('workspace.people.admin', 'Admin')
: t('workspace.people.member', 'Member')}
<Badge size="sm" variant="light" color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin", "Admin")
: t("workspace.people.member", "Member")}
</Badge>
</Table.Td>
<Table.Td>
@@ -575,9 +591,9 @@ export default function PeopleSection() {
size="sm"
maw={150}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.team.name}
@@ -587,36 +603,39 @@ export default function PeopleSection() {
<Text size="sm"></Text>
)}
</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: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
:t('never', 'Never')}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle"size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
<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:{" "}
{user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: t("never", "Never")}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
{!isCurrentUser(user) && (
{/* Actions menu */}
{!isCurrentUser(user) && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
@@ -627,25 +646,31 @@ export default function PeopleSection() {
onClick={() => openEditModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.editRole', 'Edit Role & Team')}
{t("workspace.people.editRole", "Edit Role & Team")}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{t("workspace.people.changePassword.action", "Change password")}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
leftSection={
user.enabled ? (
<LocalIcon icon="person-off" width="1rem" height="1rem" />
) : (
<LocalIcon icon="person-check" width="1rem" height="1rem" />
)
}
onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
>
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
{user.enabled ? t("workspace.people.disable") : t("workspace.people.enable")}
</Menu.Item>
)}
{!isCurrentUser(user) && isLockedUser(user) && (
@@ -654,7 +679,7 @@ export default function PeopleSection() {
onClick={() => handleUnlockUser(user)}
disabled={!loginEnabled}
>
{t('workspace.people.unlockAccount', 'Unlock Account')}
{t("workspace.people.unlockAccount", "Unlock Account")}
</Menu.Item>
)}
{!isCurrentUser(user) && user.mfaEnabled && (
@@ -666,47 +691,54 @@ export default function PeopleSection() {
onClick={async () => {
try {
await userManagementService.disableMfaByAdmin(user.username);
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
alert({
alertType: "success",
title: t(
"workspace.people.mfa.adminDisableSuccess",
"MFA disabled successfully for user",
),
});
} catch (error: unknown) {
console.error('[PeopleSection] Failed to disable MFA for user:', error);
console.error("[PeopleSection] Failed to disable MFA for user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t('workspace.people.mfa.adminDisableError', 'Failed to disable MFA for user');
alert({ alertType: 'error', title: errorMessage });
t("workspace.people.mfa.adminDisableError", "Failed to disable MFA for user");
alert({ alertType: "error", title: errorMessage });
}
}}
disabled={!loginEnabled}
>
{t('workspace.people.mfa.disableByAdmin', 'Disable MFA')}
{t("workspace.people.mfa.disableByAdmin", "Disable MFA")}
</Menu.Item>
</>
)}
{!isCurrentUser(user) && (
<>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
{t('workspace.people.deleteUser')}
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={!loginEnabled}
>
{t("workspace.people.deleteUser")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Invite Members Modal (reusable) */}
<InviteMembersModal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
/>
<InviteMembersModal opened={inviteModalOpened} onClose={() => setInviteModalOpened(false)} onSuccess={fetchData} />
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
@@ -731,34 +763,34 @@ export default function PeopleSection() {
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
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)' }} />
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.editMember.title')}
{t("workspace.people.editMember.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
{t("workspace.people.editMember.editing")} <strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t('workspace.people.editMember.role')}
label={t("workspace.people.editMember.role")}
data={roleOptions}
value={editForm.role}
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
onChange={(value) => setEditForm({ ...editForm, role: value || "ROLE_USER" })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.editMember.team')}
placeholder={t('workspace.people.editMember.teamPlaceholder')}
label={t("workspace.people.editMember.team")}
placeholder={t("workspace.people.editMember.teamPlaceholder")}
data={teamOptions}
value={editForm.teamId?.toString()}
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
@@ -766,7 +798,7 @@ export default function PeopleSection() {
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
{t('workspace.people.editMember.submit')}
{t("workspace.people.editMember.submit")}
</Button>
</Stack>
</Box>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -17,13 +17,13 @@ import {
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';
} 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;
@@ -43,8 +43,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
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 [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
// License information
@@ -64,11 +64,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([
teamService.getTeamDetails(teamId),
userManagementService.getUsers(),
]);
console.log('[TeamDetailsSection] Raw data:', data);
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 : []);
@@ -81,8 +78,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
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') });
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);
@@ -94,65 +91,70 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error('Failed to fetch teams:', 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') });
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') });
alert({ alertType: "success", title: t("workspace.teams.addMemberToTeam.success", "User added to team successfully") });
setAddMemberModalOpened(false);
setSelectedUserId('');
setSelectedUserId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error('Failed to add member:', error);
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 });
? 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?`))) {
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');
const defaultTeam = allTeams.find((t) => t.name === "Default");
if (!defaultTeam) {
throw new Error('Default team not found');
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') });
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);
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 });
? 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.');
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;
}
@@ -160,42 +162,43 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
fetchTeamDetails();
} catch (error: unknown) {
console.error('Failed to delete user:', error);
console.error("Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
? 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 });
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?');
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') });
alert({ alertType: "success", title: t("workspace.people.unlockUserSuccess", "User account unlocked successfully") });
fetchTeamDetails();
} catch (error: unknown) {
console.error('[TeamDetailsSection] Failed to unlock user:', error);
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 });
? 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() || '');
setSelectedTeamId(user.team?.id?.toString() || "");
setChangeTeamModalOpened(true);
};
@@ -211,25 +214,29 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
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') });
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('');
setSelectedTeamId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error('Failed to change team:', error);
console.error("Failed to change team:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
? 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 });
t("workspace.teams.changeTeam.error", "Failed to change team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -240,7 +247,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loadingDetails', 'Loading team details...')}
{t("workspace.teams.loadingDetails", "Loading team details...")}
</Text>
</Stack>
);
@@ -250,10 +257,10 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t('workspace.teams.teamNotFound', 'Team not found')}
{t("workspace.teams.teamNotFound", "Team not found")}
</Text>
<Button variant="light" onClick={onBack}>
{t('workspace.teams.backToTeams', 'Back to Teams')}
{t("workspace.teams.backToTeams", "Back to Teams")}
</Button>
</Stack>
);
@@ -271,7 +278,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.memberCount', { count: teamUsers.length })} {teamUsers.length === 1 ? 'member' : 'members'}
{t("workspace.teams.memberCount", { count: teamUsers.length })} {teamUsers.length === 1 ? "member" : "members"}
</Text>
</div>
</Group>
@@ -279,7 +286,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
{/* Add Member Button */}
<Group justify="flex-end">
<Tooltip
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
label={t("workspace.people.license.noSlotsAvailable", "No user slots available")}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
position="bottom"
withArrow
@@ -288,9 +295,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setAddMemberModalOpened(true)}
disabled={team.name === 'Internal' || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
disabled={team.name === "Internal" || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.teams.addMember')}
{t("workspace.teams.addMember")}
</Button>
</Tooltip>
</Group>
@@ -300,105 +307,110 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
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.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 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
<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"
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}
>
{(user.rolesAsString || '').includes('ROLE_ADMIN')
? t('workspace.people.admin')
: t('workspace.people.member')}
</Badge>
</Table.Td>
<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 */}
@@ -406,13 +418,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || 'Unknown'}
Authentication: {user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{' '}
Last Activity:{" "}
{userLastRequest[user.username]
? new Date(userLastRequest[user.username]).toLocaleString()
: 'Never'}
: "Never"}
</Text>
</div>
}
@@ -438,16 +450,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Menu.Item
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === 'Internal'}
disabled={processing || team.name === "Internal"}
>
{t('workspace.teams.changeTeam.label', 'Change Team')}
{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')}
{t("workspace.people.changePassword.action", "Change password")}
</Menu.Item>
{isLockedUser(user) && (
<Menu.Item
@@ -455,16 +467,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
onClick={() => handleUnlockUser(user)}
disabled={processing}
>
{t('workspace.people.unlockAccount', 'Unlock Account')}
{t("workspace.people.unlockAccount", "Unlock Account")}
</Menu.Item>
)}
{team.name !== 'Internal' && team.name !== 'Default' && (
{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')}
{t("workspace.teams.removeMember", "Remove from team")}
</Menu.Item>
)}
<Menu.Divider />
@@ -472,19 +484,19 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === 'Internal'}
disabled={processing || team.name === "Internal"}
>
{t('workspace.people.deleteUser', 'Delete User')}
{t("workspace.people.deleteUser", "Delete User")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
);
})
)}
</Table.Tbody>
</Table>
<ChangeUserPasswordModal
@@ -505,12 +517,12 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
@@ -519,36 +531,36 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<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)' }} />
<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')}
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{team.name}</strong>
{t("workspace.teams.addMemberToTeam.addingTo")} <strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
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})` : ''}`,
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
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')}
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
@@ -564,12 +576,12 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
@@ -578,32 +590,32 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<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)' }} />
<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')}
{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>
{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')}
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
placeholder={t("workspace.teams.changeTeam.selectTeamPlaceholder", "Choose a team")}
data={allTeams
.filter((t) => t.name !== 'Internal')
.filter((t) => t.name !== "Internal")
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || '')}
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')}
{t("workspace.teams.changeTeam.submit", "Change Team")}
</Button>
</Stack>
</div>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -16,15 +16,15 @@ import {
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';
} 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();
@@ -40,9 +40,9 @@ export default function TeamsSection() {
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState('');
const [renameTeamName, setRenameTeamName] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [newTeamName, setNewTeamName] = useState("");
const [renameTeamName, setRenameTeamName] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
useEffect(() => {
fetchTeams();
@@ -57,15 +57,15 @@ export default function TeamsSection() {
} 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 },
{ 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' });
console.error("Failed to fetch teams:", error);
alert({ alertType: "error", title: "Failed to load teams" });
} finally {
setLoading(false);
}
@@ -73,23 +73,23 @@ export default function TeamsSection() {
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.createTeam.nameRequired') });
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('');
alert({ alertType: "success", title: t("workspace.teams.createTeam.success") });
setNewTeamName("");
setCreateModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error('Failed to create team:', error);
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 });
? 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);
}
@@ -97,56 +97,55 @@ export default function TeamsSection() {
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({ alertType: 'error', title: t('workspace.teams.renameTeam.nameRequired') });
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('');
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);
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 });
? 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') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotDeleteInternal") });
return;
}
if (!confirm(t('workspace.teams.confirmDelete'))) {
if (!confirm(t("workspace.teams.confirmDelete"))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
alert({ alertType: "success", title: t("workspace.teams.deleteTeam.success") });
await fetchTeams();
} catch (error: unknown) {
console.error('Failed to delete team:', error);
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 });
? 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') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotRenameInternal") });
return;
}
setSelectedTeam(team);
@@ -155,8 +154,8 @@ export default function TeamsSection() {
};
const openAddMemberModal = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotAddToInternal") });
return;
}
setSelectedTeam(team);
@@ -166,28 +165,28 @@ export default function TeamsSection() {
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error('Failed to fetch users:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.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') });
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('');
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') });
console.error("Failed to add member to team:", error);
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.error") });
} finally {
setProcessing(false);
}
@@ -211,7 +210,7 @@ export default function TeamsSection() {
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loading', 'Loading teams...')}
{t("workspace.teams.loading", "Loading teams...")}
</Text>
</Stack>
);
@@ -222,17 +221,21 @@ export default function TeamsSection() {
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
{t("workspace.teams.title")}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.description')}
{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
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={() => setCreateModalOpened(true)}
disabled={!loginEnabled}
>
{t("workspace.teams.createNewTeam")}
</Button>
</Group>
@@ -242,96 +245,112 @@ export default function TeamsSection() {
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
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.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 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')}
<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>
) : (
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.Tbody>
</Table>
{/* Create Team Modal */}
@@ -344,36 +363,36 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
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)' }} />
<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')}
{t("workspace.teams.createTeam.title")}
</Text>
</Stack>
<TextInput
label={t('workspace.teams.createTeam.teamName')}
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
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')}
{t("workspace.teams.createTeam.submit")}
</Button>
</Stack>
</div>
@@ -389,39 +408,39 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
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)' }} />
<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')}
{t("workspace.teams.renameTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.renameTeam.renaming')} <strong>{selectedTeam?.name}</strong>
{t("workspace.teams.renameTeam.renaming")} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t('workspace.teams.renameTeam.newTeamName')}
placeholder={t('workspace.teams.renameTeam.newTeamNamePlaceholder')}
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')}
{t("workspace.teams.renameTeam.submit")}
</Button>
</Stack>
</div>
@@ -437,50 +456,50 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
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)' }} />
<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')}
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{selectedTeam?.name}</strong>
{t("workspace.teams.addMemberToTeam.addingTo")} <strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t('workspace.teams.addMemberToTeam.selectUser')}
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
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})` : ''}`,
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
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')}
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
@@ -1,10 +1,5 @@
import React from "react";
import {
Box,
Button,
Group,
Paper,
} from "@mantine/core";
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";
@@ -17,17 +12,19 @@ interface ApiKeySectionProps {
disabled?: boolean;
}
export default function ApiKeySection({
publicKey,
copied,
onCopy,
onRefresh,
disabled,
}: ApiKeySectionProps) {
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)" }}>
<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
@@ -36,13 +33,14 @@ export default function ApiKeySection({
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',
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')}
aria-label={t("config.apiKeys.publicKeyAriaLabel", "Public API key")}
>
<FitText text={publicKey} />
</Box>
@@ -52,21 +50,35 @@ export default function ApiKeySection({
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')}
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')}
{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 } }}
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')}
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
>
{t('common.refresh', 'Refresh')}
{t("common.refresh", "Refresh")}
</Button>
</Group>
</Paper>
@@ -1,11 +1,5 @@
import React from "react";
import {
Modal,
Stack,
Text,
Group,
Button,
} from "@mantine/core";
import { Modal, Stack, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
@@ -21,27 +15,33 @@ export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModa
<Modal
opened={opened}
onClose={onClose}
title={t('config.apiKeys.refreshModal.title', 'Refresh API Keys')}
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.')}
{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.')}
{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?')}
{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')}
{t("common.cancel", "Cancel")}
</Button>
<Button color="red" onClick={onConfirm}>
{t('config.apiKeys.refreshModal.confirmCta', 'Refresh Keys')}
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
</Button>
</Group>
</Stack>
@@ -78,38 +78,42 @@ export function useApiKey() {
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 {
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,
});
}
}).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);
});
setError(e);
}).finally(() => {
setIsRefreshing(false);
});
}, []);
useEffect(() => {
@@ -1,56 +1,35 @@
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';
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)',
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)';
return EVENT_TYPE_COLORS[type] || "var(--mantine-color-blue-6)";
};
interface AuditChartsSectionProps {
loginEnabled?: boolean;
timePeriod?: 'day' | 'week' | 'month';
onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void;
timePeriod?: "day" | "week" | "month";
onTimePeriodChange?: (period: "day" | "week" | "month") => void;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
loginEnabled = true,
timePeriod = 'week',
timePeriod = "week",
onTimePeriodChange,
}) => {
const { t } = useTranslation();
@@ -66,7 +45,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
const data = await auditService.getChartsData(timePeriod);
setChartsData(data);
} catch (err) {
setError(err instanceof Error ? err.message : t('audit.charts.error', 'Failed to load charts'));
setError(err instanceof Error ? err.message : t("audit.charts.error", "Failed to load charts"));
} finally {
setLoading(false);
}
@@ -78,15 +57,15 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
// Demo data when login disabled
setChartsData({
eventsByType: {
labels: ['LOGIN', 'LOGOUT', 'SETTINGS_CHANGE', 'FILE_UPLOAD', 'FILE_DOWNLOAD'],
labels: ["LOGIN", "LOGOUT", "SETTINGS_CHANGE", "FILE_UPLOAD", "FILE_DOWNLOAD"],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ['admin', 'user1', 'user2', 'user3', 'user4'],
labels: ["admin", "user1", "user2", "user3", "user4"],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
@@ -106,7 +85,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
if (error) {
return (
<Alert color="red" title={t('audit.charts.error', 'Error loading charts')}>
<Alert color="red" title={t("audit.charts.error", "Error loading charts")}>
{error}
</Alert>
);
@@ -137,18 +116,18 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
{/* Header with time period selector */}
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('audit.charts.title', 'Audit Dashboard')}
{t("audit.charts.title", "Audit Dashboard")}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
onTimePeriodChange?.(value as 'day' | 'week' | 'month');
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' },
{ 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>
@@ -157,9 +136,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.overTime', 'Events Over Time')}
{t("audit.charts.overTime", "Events Over Time")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsOverTimeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={eventsOverTimeData}>
@@ -174,10 +153,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<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)',
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Area
@@ -191,7 +170,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data for this period')}</Text>
<Text c="dimmed">{t("audit.charts.noData", "No data for this period")}</Text>
</Group>
)}
</Box>
@@ -204,9 +183,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byType', 'Events by Type')}
{t("audit.charts.byType", "Events by Type")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsByTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByTypeData}>
@@ -215,10 +194,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<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)',
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)">
@@ -230,7 +209,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
@@ -241,9 +220,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byUser', 'Top Users')}
{t("audit.charts.byUser", "Top Users")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
@@ -252,10 +231,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<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)',
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)" />
@@ -263,7 +242,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
@@ -1,8 +1,8 @@
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';
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;
@@ -10,8 +10,8 @@ interface AuditClearDataSectionProps {
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState('');
const [generatedCode, setGeneratedCode] = useState('');
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);
@@ -20,21 +20,21 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
const handleInitiateDeletion = () => {
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
setGeneratedCode(code);
setConfirmationCode('');
setConfirmationCode("");
setShowConfirmation(true);
setError(null);
};
const resetForm = () => {
setConfirmationCode('');
setGeneratedCode('');
setConfirmationCode("");
setGeneratedCode("");
setShowConfirmation(false);
setError(null);
};
const handleClearData = async () => {
if (confirmationCode !== generatedCode) {
setError(t('audit.clearData.codeDoesNotMatch', 'Code does not match'));
setError(t("audit.clearData.codeDoesNotMatch", "Code does not match"));
return;
}
@@ -47,7 +47,7 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
// Auto-dismiss success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear audit data');
setError(err instanceof Error ? err.message : "Failed to clear audit data");
} finally {
setClearing(false);
}
@@ -59,12 +59,12 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="green"
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.success', 'Success')}
title={t("audit.clearData.success", "Success")}
onClose={() => setSuccess(false)}
closeButtonLabel="Close alert"
withCloseButton
>
{t('audit.clearData.successMessage', 'All audit data has been cleared successfully')}
{t("audit.clearData.successMessage", "All audit data has been cleared successfully")}
</Alert>
</Stack>
);
@@ -76,52 +76,55 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="orange"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.confirmTitle', 'Please confirm you want to delete')}
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.')}
{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)' }}>
<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)',
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')}
{t("audit.clearData.confirmationCode", "Confirmation Code")}
</Text>
<Code
style={{
fontSize: '1.5rem',
letterSpacing: '0.15em',
fontSize: "1.5rem",
letterSpacing: "0.15em",
fontWeight: 600,
display: 'block',
textAlign: 'center',
padding: '0.75rem',
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)')}
{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')}
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')
? t("audit.clearData.codeDoesNotMatch", "Code does not match")
: false
}
/>
@@ -134,7 +137,7 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Group justify="space-between">
<Button variant="default" onClick={resetForm} disabled={clearing}>
{t('audit.clearData.cancel', 'Cancel')}
{t("audit.clearData.cancel", "Cancel")}
</Button>
<Button
color="red"
@@ -142,7 +145,7 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
loading={clearing}
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
>
{t('audit.clearData.deleteButton', 'Delete')}
{t("audit.clearData.deleteButton", "Delete")}
</Button>
</Group>
</Stack>
@@ -156,31 +159,25 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="red"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.warning1', 'This action cannot be undone')}
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.')}
{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)' }}>
<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>
<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 color="red" onClick={handleInitiateDeletion} disabled={!loginEnabled} fullWidth>
{t("audit.clearData.initiateDelete", "Delete All Data")}
</Button>
</Stack>
</Card>
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Card,
Text,
@@ -13,13 +13,13 @@ import {
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';
} 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;
@@ -39,17 +39,20 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
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 [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);
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters(
{
page: 0,
pageSize: 20,
},
loginEnabled,
);
useEffect(() => {
const fetchEvents = async () => {
@@ -63,7 +66,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
setEvents(response.events);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load events');
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
@@ -76,44 +79,44 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
const now = new Date();
setEvents([
{
id: '1',
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' },
eventType: "LOGIN",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "User logged in successfully" },
},
{
id: '2',
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' },
eventType: "FILE_UPLOAD",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "Uploaded document.pdf" },
},
{
id: '3',
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' },
eventType: "SETTINGS_CHANGE",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "Modified system settings" },
},
{
id: '4',
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' },
eventType: "FILE_DOWNLOAD",
username: "user2",
ipAddress: "192.168.1.102",
details: { message: "Downloaded report.pdf" },
},
{
id: '5',
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' },
eventType: "LOGOUT",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "User logged out" },
},
]);
setTotalPages(1);
@@ -137,35 +140,35 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
};
// Sort handling
const toggleSort = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
const toggleSort = (key: "timestamp" | "eventType" | "username" | "ipAddress") => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir('asc');
setSortDir("asc");
}
};
const getSortIcon = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
if (sortKey !== key) return 'unfold-more';
return sortDir === 'asc' ? 'expand-less' : 'expand-more';
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',
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';
return EVENT_TYPE_COLORS[type] || "blue";
};
// Apply sorting to current events
@@ -174,19 +177,19 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
let bVal: string | number | undefined;
switch (sortKey) {
case 'timestamp':
case "timestamp":
aVal = new Date(a.timestamp).getTime();
bVal = new Date(b.timestamp).getTime();
break;
case 'eventType':
case "eventType":
aVal = a.eventType;
bVal = b.eventType;
break;
case 'username':
case "username":
aVal = a.username;
bVal = b.username;
break;
case 'ipAddress':
case "ipAddress":
aVal = a.ipAddress;
bVal = b.ipAddress;
break;
@@ -194,8 +197,8 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
return 0;
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
@@ -203,7 +206,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.events.title', 'Audit Events')}
{t("audit.events.title", "Audit Events")}
</Text>
{/* Filters */}
@@ -218,153 +221,161 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
{/* Table */}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ display: "flex", justifyContent: "center" }}>
<Loader size="lg" my="xl" />
</div>
) : error ? (
<Alert color="red" title={t('audit.events.error', 'Error loading events')}>
<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')}
<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>
)}
{showFileHash && (
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.fileHash', 'File Hash')}
<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.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) + '...' : '';
</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 && (
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{author}</Text>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text size="sm" title={fileHash} style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{fileHash}
<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>
)}
<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>
{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>
)}
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination value={currentPage} onChange={setCurrentPage} total={totalPages} />
</Group>
)}
</div>
</>
)}
@@ -374,7 +385,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Modal
opened={selectedEvent !== null}
onClose={() => setSelectedEvent(null)}
title={t('audit.events.eventDetails', 'Event Details')}
title={t("audit.events.eventDetails", "Event Details")}
size="lg"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
@@ -382,33 +393,33 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Stack gap="md">
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.timestamp', 'Timestamp')}
{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')}
{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')}
{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')}
{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')}
{t("audit.events.details", "Details")}
</Text>
<Code block mah={300} style={{ overflow: 'auto' }}>
<Code block mah={300} style={{ overflow: "auto" }}>
{JSON.stringify(selectedEvent.details, null, 2)}
</Code>
</div>
@@ -1,18 +1,10 @@
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';
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;
@@ -28,7 +20,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
captureOperationResults = false,
}) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exportFormat, setExportFormat] = useState<"csv" | "json">("csv");
const [exporting, setExporting] = useState(false);
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>({
date: true,
@@ -51,13 +43,15 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
try {
setExporting(true);
const fieldsParam = Object.keys(selectedFields).filter(k => selectedFields[k]).join(',');
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');
const link = document.createElement("a");
link.href = url;
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
document.body.appendChild(link);
@@ -65,8 +59,8 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Export failed:', err);
alert(t('audit.export.error', 'Failed to export data'));
console.error("Export failed:", err);
alert(t("audit.export.error", "Failed to export data"));
} finally {
setExporting(false);
}
@@ -76,108 +70,105 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.export.title', 'Export Audit Data')}
{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.'
)}
{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')}
{t("audit.export.format", "Export Format")}
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as 'csv' | 'json');
setExportFormat(value as "csv" | "json");
}}
disabled={!loginEnabled}
data={[
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' },
{ 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">
<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.fieldDate', 'Date')}
checked={selectedFields.date}
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
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.fieldUsername', 'Username')}
checked={selectedFields.username}
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
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.fieldIpAddress', 'IP Address')}
checked={selectedFields.ipaddress}
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
label={t("audit.export.fieldOperationResults", "Operation Results")}
checked={selectedFields.operationResults}
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: 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>
)}
</Stack>
</div>
{/* Filters */}
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.export.filters', 'Filters (Optional)')}
{t("audit.export.filters", "Filters (Optional)")}
</Text>
<AuditFiltersForm
filters={filters}
@@ -197,7 +188,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
loading={exporting}
disabled={!loginEnabled || exporting}
>
{t('audit.export.exportButton', 'Export Data')}
{t("audit.export.exportButton", "Export Data")}
</Button>
</Group>
</Stack>
@@ -1,15 +1,15 @@
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';
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');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -19,17 +19,17 @@ const getDateRange = (preset: string): [Date, Date] | null => {
const start = new Date();
switch (preset) {
case 'today':
case "today":
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
case 'last7':
case "last7":
start.setDate(start.getDate() - 6);
return [start, end];
case 'last30':
case "last30":
start.setDate(start.getDate() - 29);
return [start, end];
case 'thisMonth':
case "thisMonth":
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
@@ -65,8 +65,8 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
const range = getDateRange(preset);
if (range) {
const [start, end] = range;
onFilterChange('startDate', formatDateToYMD(start));
onFilterChange('endDate', formatDateToYMD(end));
onFilterChange("startDate", formatDateToYMD(start));
onFilterChange("endDate", formatDateToYMD(end));
}
};
@@ -85,40 +85,40 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Quick Preset Buttons */}
<div>
<Text size="xs" fw={600} mb="xs" c="dimmed">
{t('audit.filters.quickPresets', 'Quick filters')}
{t("audit.filters.quickPresets", "Quick filters")}
</Text>
<Group gap="xs">
<Button
variant={isPresetActive('today') ? 'filled' : 'light'}
variant={isPresetActive("today") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('today')}
onClick={() => handleQuickPreset("today")}
disabled={disabled}
>
{t('audit.filters.today', 'Today')}
{t("audit.filters.today", "Today")}
</Button>
<Button
variant={isPresetActive('last7') ? 'filled' : 'light'}
variant={isPresetActive("last7") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('last7')}
onClick={() => handleQuickPreset("last7")}
disabled={disabled}
>
{t('audit.filters.last7Days', 'Last 7 days')}
{t("audit.filters.last7Days", "Last 7 days")}
</Button>
<Button
variant={isPresetActive('last30') ? 'filled' : 'light'}
variant={isPresetActive("last30") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('last30')}
onClick={() => handleQuickPreset("last30")}
disabled={disabled}
>
{t('audit.filters.last30Days', 'Last 30 days')}
{t("audit.filters.last30Days", "Last 30 days")}
</Button>
<Button
variant={isPresetActive('thisMonth') ? 'filled' : 'light'}
variant={isPresetActive("thisMonth") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('thisMonth')}
onClick={() => handleQuickPreset("thisMonth")}
disabled={disabled}
>
{t('audit.filters.thisMonth', 'This month')}
{t("audit.filters.thisMonth", "This month")}
</Button>
</Group>
</div>
@@ -126,39 +126,39 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Filter Inputs */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
<MultiSelect
placeholder={t('audit.events.filterByType', 'Filter by type')}
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)}
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')}
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)}
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')}
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);
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')}
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);
onFilterChange("endDate", value ? formatDateToYMD(new Date(value)) : undefined);
}}
clearable
disabled={disabled}
@@ -169,7 +169,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Clear Button */}
<Group justify="flex-end">
<Button variant="outline" size="sm" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
{t("audit.events.clearFilters", "Clear")}
</Button>
</Group>
</Stack>
@@ -1,15 +1,15 @@
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';
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';
timePeriod: "day" | "week" | "month";
}
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = 'week' }) => {
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = "week" }) => {
const { t } = useTranslation();
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
@@ -23,7 +23,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
const data = await auditService.getStats(timePeriod);
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load statistics');
setError(err instanceof Error ? err.message : "Failed to load statistics");
} finally {
setLoading(false);
}
@@ -43,8 +43,8 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
avgLatencyMs: 342,
prevAvgLatencyMs: 385,
errorCount: 148,
topEventType: 'PDF_PROCESS',
topUser: 'admin',
topEventType: "PDF_PROCESS",
topUser: "admin",
eventsByType: {},
eventsByUser: {},
topTools: {},
@@ -57,7 +57,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<Loader size="lg" />
</div>
</Card>
@@ -66,7 +66,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
if (error) {
return (
<Alert color="red" title={t('audit.stats.error', 'Error loading statistics')}>
<Alert color="red" title={t("audit.stats.error", "Error loading statistics")}>
{error}
</Alert>
);
@@ -76,27 +76,30 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
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 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';
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";
}
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 isPositive ? "trending-up" : "trending-down";
};
return (
@@ -106,7 +109,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.totalEvents', 'Total Events')}
{t("audit.stats.totalEvents", "Total Events")}
</Text>
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
</Group>
@@ -123,11 +126,11 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
icon={getTrendIcon(trendPercent)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(trendPercent).toFixed(1)}% {t('audit.stats.vsLastPeriod', 'vs last period')}
{Math.abs(trendPercent).toFixed(1)}% {t("audit.stats.vsLastPeriod", "vs last period")}
</Badge>
)}
</Stack>
@@ -138,7 +141,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.successRate', 'Success Rate')}
{t("audit.stats.successRate", "Success Rate")}
</Text>
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
</Group>
@@ -148,14 +151,15 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Group gap="xs">
<Badge color={getSuccessRateColor(stats.successRate)} variant="light" size="sm">
{stats.successRate >= 95
? t('audit.stats.excellent', 'Excellent')
? t("audit.stats.excellent", "Excellent")
: stats.successRate >= 80
? t('audit.stats.good', 'Good')
: t('audit.stats.attention', 'Attention needed')}
? 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)}%
{successTrend > 0 ? "+" : ""}
{successTrend.toFixed(1)}%
</Badge>
)}
</Group>
@@ -167,7 +171,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.activeUsers', 'Active Users')}
{t("audit.stats.activeUsers", "Active Users")}
</Text>
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
</Group>
@@ -180,12 +184,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(userTrend)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
/>
<LocalIcon icon={getTrendIcon(userTrend)} width="0.8rem" height="0.8rem" style={{ marginRight: "0.25rem" }} />
}
>
{Math.abs(userTrend).toFixed(1)}%
@@ -199,12 +198,12 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.avgLatency', 'Avg Latency')}
{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')}
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t("audit.stats.noData", "N/A")}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
@@ -216,7 +215,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
icon={getTrendIcon(latencyTrend, true)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
style={{ marginRight: "0.25rem" }}
/>
}
>
@@ -1,7 +1,7 @@
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';
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;
@@ -14,24 +14,22 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.systemStatus.title', 'System Status')}
{t("audit.systemStatus.title", "System Status")}
</Text>
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.status', 'Audit Logging')}
{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 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')}
{t("audit.systemStatus.level", "Audit Level")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.level}
@@ -40,16 +38,16 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.retention', 'Retention Period')}
{t("audit.systemStatus.retention", "Retention Period")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.retentionDays} {t('audit.systemStatus.days', 'days')}
{status.retentionDays} {t("audit.systemStatus.days", "days")}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.totalEvents', 'Total Events')}
{t("audit.systemStatus.totalEvents", "Total Events")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.totalEvents.toLocaleString()}
@@ -61,34 +59,34 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.systemStatus.capturedFields', 'Captured Fields')}
{t("audit.systemStatus.capturedFields", "Captured Fields")}
</Text>
<Group gap="xs">
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.username', 'Username')}
{t("audit.systemStatus.username", "Username")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.documentName', 'Document Name')}
{t("audit.systemStatus.documentName", "Document Name")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.tool', 'Tool')}
{t("audit.systemStatus.tool", "Tool")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.date', 'Date')}
{t("audit.systemStatus.date", "Date")}
</Badge>
<Badge color={status.capturePdfAuthor ? 'green' : 'gray'} variant="light" size="sm">
{t('audit.systemStatus.pdfAuthor', 'PDF Author')}
<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 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')}
<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 style={{ marginLeft: "0.5rem", fontSize: "0.75rem", opacity: 0.7 }}>
({t("audit.systemStatus.captureBySettings", "Enable in settings")})
</span>
)}
</Badge>
@@ -1,11 +1,11 @@
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';
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[];
@@ -56,23 +56,23 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<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 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',
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')}
{t("plan.availablePlans.subtitle", "Choose the plan that fits your needs")}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || 'usd')}
onChange={(value) => onCurrencyChange(value || "usd")}
data={currencyOptions}
searchable
clearable={false}
@@ -85,10 +85,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.1rem',
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
marginBottom: "0.1rem",
}}
>
{groupedPlans.map((group) => (
@@ -106,11 +106,11 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
))}
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
@@ -1,7 +1,7 @@
import React from 'react';
import { Card, Badge, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanFeature } from '@app/services/licenseService';
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;
@@ -12,48 +12,46 @@ interface PlanWithFeatures {
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: 'free' | 'server' | 'enterprise' | null;
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' }}>
<Card padding="lg" radius="md" withBorder style={{ marginTop: "1rem" }}>
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<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>
<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'
textAlign: "center",
padding: "0.75rem",
minWidth: "8rem",
position: "relative",
}}
>
{plan.name}
{plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && (
{plan.popular && !(plan.tier === "server" && currentTier === "enterprise") && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
position: "absolute",
top: "0.5rem",
right: "0.5rem",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
@@ -62,15 +60,10 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans,
</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>
<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' }}>
<td key={planIndex} style={{ textAlign: "center", padding: "0.75rem" }}>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
@@ -1,12 +1,12 @@
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';
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;
@@ -17,9 +17,9 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [licenseKeyInput, setLicenseKeyInput] = useState<string>("");
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [inputMethod, setInputMethod] = useState<"text" | "file">("text");
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
@@ -33,17 +33,17 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
let response;
if (inputMethod === 'file' && licenseFile) {
if (inputMethod === "file" && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
} 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'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key or file"),
});
return;
}
@@ -53,33 +53,33 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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');
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'),
alertType: "success",
title: t("success", "Success"),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseKeyInput("");
setLicenseFile(null);
setInputMethod('text'); // Reset to default
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'),
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);
console.error("Failed to save license:", error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
@@ -91,15 +91,11 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={showLicenseKey ? 'expand-less-rounded' : 'expand-more-rounded'}
width="1.25rem"
height="1.25rem"
/>
<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?')}
{t("admin.settings.premium.licenseKey.toggle", "Got a license key or certificate file?")}
</Button>
<Collapse in={showLicenseKey} mt="md">
@@ -107,8 +103,8 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<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.'
"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>
@@ -119,25 +115,25 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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')}
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.'
"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.'
"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.'
"admin.settings.premium.key.overwriteWarning.line3",
"Important: Keep license keys private and secure. Never share them publicly.",
)}
</Text>
</Stack>
@@ -146,24 +142,20 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<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')}
{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}})', {
{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')}
: t("admin.settings.premium.currentLicense.key", "Source: License key")}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
{t("admin.settings.premium.currentLicense.type", "Type: {{type}}", {
type: currentLicenseInfo.licenseType,
})}
</Text>
@@ -175,19 +167,19 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
setInputMethod(value as "text" | "file");
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
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.text", "License Key"),
value: "text",
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file',
label: t("admin.settings.premium.inputMethod.file", "Certificate File"),
value: "file",
},
]}
disabled={!loginEnabled || savingLicense}
@@ -196,17 +188,17 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
{inputMethod === "text" ? (
/* Text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
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.'
"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'}
placeholder={currentLicenseInfo?.licenseKey || "00000000-0000-0000-0000-000000000000"}
type="password"
disabled={!loginEnabled || savingLicense}
/>
@@ -214,16 +206,12 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
{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')}
{t("admin.settings.premium.file.description", "Upload your .lic or .cert license file")}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
<FileButton onChange={setLicenseFile} accept=".lic,.cert" disabled={!loginEnabled || savingLicense}>
{(props) => (
<Button
{...props}
@@ -231,17 +219,15 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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')}
{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}})', {
{t("admin.settings.premium.file.selected", "Selected: {{filename}} ({{size}})", {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
size: (licenseFile.size / 1024).toFixed(2) + " KB",
})}
</Text>
)}
@@ -255,11 +241,11 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
(inputMethod === "text" && !licenseKeyInput.trim()) ||
(inputMethod === "file" && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
</Stack>
@@ -1,60 +1,53 @@
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';
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;
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 PlanCard: React.FC<PlanCardProps> = ({
planGroup,
isCurrentTier,
isDowngrade,
currentLicenseInfo,
currentTier,
onUpgradeClick,
onManageClick,
loginEnabled = true,
}) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
if (planGroup.tier === "free") {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || '$';
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%' }}>
<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')}
{t("plan.from", "From")}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t('plan.free.forever', 'Forever free')}
/>
<PriceDisplay mode="simple" price={0} currency={freeCurrency} period={t("plan.free.forever", "Forever free")} />
</div>
<Divider />
@@ -70,9 +63,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth className="plan-button">
{isCurrentTier
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
{isCurrentTier ? t("plan.current", "Current Plan") : t("plan.free.included", "Included")}
</Button>
</Stack>
</Card>
@@ -81,7 +72,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
const isEnterprise = planGroup.tier === "enterprise";
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier);
@@ -89,30 +80,18 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
monthly || undefined,
yearly || undefined
yearly || undefined,
);
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
<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')}
/>
<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%' }}>
<Stack gap="md" style={{ height: "100%" }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
@@ -120,20 +99,21 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('plan.from', 'From')}
{t("plan.from", "From")}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}{displaySeatPrice.toFixed(2)}
{displayCurrency}
{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t('plan.perSeat', '/seat')}
{t("plan.perSeat", "/seat")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')} {t('plan.withServer', '+ Server Plan')}
{t("plan.perMonth", "/month")} {t("plan.withServer", "+ Server Plan")}
</Text>
</>
) : (
@@ -141,7 +121,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t('plan.perMonth', '/month')}
period={t("plan.perMonth", "/month")}
/>
)}
</div>
@@ -159,40 +139,39 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<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>
)}
<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"
{/* Single Upgrade Button */}
<Tooltip
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
{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>
<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>
@@ -1,75 +1,70 @@
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';
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';
planName: "server" | "enterprise";
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
type Stage = "email" | "period-selection" | "license-activation";
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
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 [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 [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');
setEmailError("");
setStageHistory([...stageHistory, "email"]);
setStage("period-selection");
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
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');
window.open(urlWithEmail, "_blank");
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
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'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key"),
});
return;
}
@@ -85,26 +80,23 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
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'),
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);
console.error("Failed to save license:", error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
@@ -124,50 +116,43 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStage("email");
setEmail("");
setEmailError("");
setStageHistory([]);
setLicenseKey('');
setLicenseKey("");
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (stage === "email") {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
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');
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 === "period-selection") {
return t("plan.static.selectPeriod", "Select Billing Period");
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
if (stage === "license-activation") {
return t("plan.static.activateLicense", "Activate Your License");
}
return '';
return "";
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case "email":
return <EmailStage emailInput={email} setEmailInput={setEmail} emailError={emailError} onSubmit={handleEmailSubmit} />;
case 'period-selection':
case "period-selection":
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
@@ -175,14 +160,14 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
onClick={() => handlePeriodSelect("monthly")}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Stack gap="md" style={{ height: "100%", minHeight: "120px" }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
{t("payment.monthly", "Monthly")}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
{t("plan.static.monthlyBilling", "Monthly Billing")}
</Text>
</Stack>
</Paper>
@@ -195,14 +180,14 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
onClick={() => handlePeriodSelect("yearly")}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Stack gap="md" style={{ height: "100%", minHeight: "120px" }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
{t("payment.yearly", "Yearly")}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
{t("plan.static.yearlyBilling", "Yearly Billing")}
</Text>
</Stack>
</Paper>
@@ -211,22 +196,18 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
</Stack>
);
case 'license-activation':
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="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')}
{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.'
"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>
@@ -237,30 +218,24 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
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.'
"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:'
)}
{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'
)}
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"
@@ -270,14 +245,10 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
{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 onClick={handleActivateLicense} loading={savingLicense} disabled={!licenseKey.trim()}>
{t("plan.static.licenseActivation.activate", "Activate License")}
</Button>
</Group>
</Stack>
@@ -285,9 +256,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
<Button onClick={handleClose}>{t("common.close", "Close")}</Button>
</Group>
)}
</Stack>
@@ -298,7 +267,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
const canGoBack = stageHistory.length > 0 && stage !== "license-activation";
return (
<Modal
@@ -307,12 +276,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t('common.back', 'Back')}
>
<ActionIcon variant="subtle" size="lg" onClick={handleGoBack} aria-label={t("common.back", "Back")}>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
@@ -321,7 +285,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
size={isMobile ? "100%" : 600}
centered
radius="lg"
withCloseButton={true}
@@ -1,16 +1,20 @@
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';
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;
@@ -22,19 +26,19 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server');
const [selectedPlan, setSelectedPlan] = useState<"server" | "enterprise">("server");
const [isUpgrade, setIsUpgrade] = useState(false);
const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => {
const handleOpenCheckout = (plan: "server" | "enterprise", upgrade: boolean) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === 'free' && plan === 'enterprise') {
if (currentTier === "free" && plan === "enterprise") {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
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.'
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
@@ -48,84 +52,83 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: 'warning',
title: t('plan.static.billingPortal.title', 'Email Verification Required'),
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.'
"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');
window.open(STATIC_STRIPE_LINKS.billingPortal, "_blank");
};
const staticPlans = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: '£',
period: '',
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: 'server',
name: 'Server',
id: "server",
name: "Server",
price: 0,
currency: '',
period: '',
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: 'Unlimited users',
maxUsers: "Unlimited users",
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: '',
period: '',
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: 'Custom',
maxUsers: "Custom",
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === 'enterprise') return staticPlans[2];
if (tier === 'server') return staticPlans[1];
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' }}>
<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 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',
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')}
{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',
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
paddingBottom: "0.1rem",
}}
>
{staticPlans.map((plan) => (
@@ -137,28 +140,20 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
{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')}
/>
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
)}
<Stack gap="md" style={{ height: '100%' }}>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
<Text size="xl" fw={700} style={{ fontSize: "2rem" }}>
{plan.name}
</Text>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
{typeof plan.maxUsers === "string"
? plan.maxUsers
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
: `${t("plan.static.upTo", "Up to")} ${plan.maxUsers} ${t("workspace.people.license.users", "users")}`}
</Text>
</div>
@@ -179,78 +174,56 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
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 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') {
if (plan.id === "server") {
if (currentTier === "free") {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
onClick={() => handleOpenCheckout("server", false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
{t("plan.upgrade", "Upgrade")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
<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 variant="filled" disabled fullWidth className="plan-button">
{t("plan.free.included", "Included")}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
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')}
<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') {
if (currentTier === "server") {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
@@ -263,25 +236,15 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
<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 variant="filled" fullWidth onClick={handleManageBilling} className="plan-button">
{t("plan.manage", "Manage")}
</Button>
);
}
@@ -295,11 +258,11 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<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')}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import { Card, Text, Stack, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
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 }[];
@@ -14,7 +14,7 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
<Stack gap="sm">
{data.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('usage.noData', 'No data available')}
{t("usage.noData", "No data available")}
</Text>
) : (
data.map((item, index) => (
@@ -25,9 +25,9 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
c="dimmed"
maw="60%"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
@@ -43,19 +43,19 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
</Group>
<Box
style={{
width: '100%',
height: '0.5rem',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden',
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',
height: "100%",
backgroundColor: "var(--mantine-color-blue-6)",
transition: "width 0.3s ease",
}}
/>
</Box>
@@ -83,7 +83,7 @@ const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('usage.chart.title', 'Endpoint Usage Chart')}
{t("usage.chart.title", "Endpoint Usage Chart")}
</Text>
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
</Stack>
@@ -1,17 +1,7 @@
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';
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[];
@@ -24,7 +14,7 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('usage.table.title', 'Detailed Statistics')}
{t("usage.table.title", "Detailed Statistics")}
</Text>
<Table
@@ -32,23 +22,25 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
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%">
<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 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 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 style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="20%" ta="right">
{t("usage.table.percentage", "Percentage")}
</TableTh>
</TableTr>
</TableThead>
@@ -57,7 +49,7 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
<TableTr>
<TableTd colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('usage.table.noData', 'No data available')}
{t("usage.table.noData", "No data available")}
</Text>
</TableTd>
</TableTr>