mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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:
@@ -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";
|
||||
|
||||
+142
-133
@@ -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>
|
||||
|
||||
+1071
-864
File diff suppressed because it is too large
Load Diff
+45
-57
@@ -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>
|
||||
|
||||
+332
-304
@@ -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
|
||||
|
||||
+421
-422
@@ -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>
|
||||
);
|
||||
|
||||
+250
-231
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+181
-165
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+755
-626
File diff suppressed because it is too large
Load Diff
+132
-133
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+166
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-52
@@ -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}
|
||||
|
||||
+111
-98
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+154
-148
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+962
-760
File diff suppressed because it is too large
Load Diff
+243
-237
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+84
-90
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+255
-223
@@ -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>
|
||||
|
||||
+202
-190
@@ -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>
|
||||
|
||||
+187
-168
@@ -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>
|
||||
|
||||
+34
-22
@@ -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>
|
||||
|
||||
+13
-13
@@ -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>
|
||||
|
||||
+29
-25
@@ -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(() => {
|
||||
|
||||
+49
-70
@@ -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>
|
||||
|
||||
+46
-49
@@ -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>
|
||||
|
||||
+209
-198
@@ -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>
|
||||
|
||||
+78
-87
@@ -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>
|
||||
|
||||
+38
-38
@@ -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>
|
||||
|
||||
+38
-39
@@ -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" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
+25
-27
@@ -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>
|
||||
|
||||
+22
-22
@@ -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>
|
||||
|
||||
|
||||
+23
-30
@@ -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">
|
||||
✓
|
||||
|
||||
+68
-82
@@ -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>
|
||||
|
||||
+72
-93
@@ -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>
|
||||
|
||||
+86
-122
@@ -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}
|
||||
|
||||
+85
-122
@@ -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>
|
||||
|
||||
|
||||
+16
-16
@@ -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>
|
||||
|
||||
+19
-27
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user