team updates

This commit is contained in:
Anthony Stirling 2025-10-30 13:30:17 +00:00
parent e88c69be70
commit cf2c7517eb
15 changed files with 298 additions and 92 deletions

View File

@ -36,15 +36,14 @@ public class UsageRestController {
* events to generate usage statistics.
*
* @param limit Optional limit on number of endpoints to return
* @param includeHome Whether to include homepage ("/") in results
* @param includeLogin Whether to include login page ("/login") in results
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
* auth), or "ui" (non-API endpoints)
* @return Endpoint statistics response
*/
@GetMapping("/usage-endpoint-statistics")
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "includeHome", defaultValue = "true") boolean includeHome,
@RequestParam(value = "includeLogin", defaultValue = "true") boolean includeLogin) {
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
// Get all HTTP_REQUEST audit events
List<PersistentAuditEvent> httpEvents =
@ -56,11 +55,8 @@ public class UsageRestController {
for (PersistentAuditEvent event : httpEvents) {
String endpoint = extractEndpointFromAuditData(event.getData());
if (endpoint != null) {
// Apply filters
if (!includeHome && "/".equals(endpoint)) {
continue;
}
if (!includeLogin && "/login".equals(endpoint)) {
// Apply data type filter
if (!shouldIncludeEndpoint(endpoint, dataType)) {
continue;
}
@ -171,6 +167,55 @@ public class UsageRestController {
return endpoint;
}
/**
* Determine if an endpoint should be included based on the data type filter.
*
* @param endpoint The endpoint path to check
* @param dataType The filter type: "all", "api", or "ui"
* @return true if the endpoint should be included, false otherwise
*/
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
if ("all".equalsIgnoreCase(dataType)) {
return true;
}
boolean isApiEndpoint = isApiEndpoint(endpoint);
if ("api".equalsIgnoreCase(dataType)) {
return isApiEndpoint;
} else if ("ui".equalsIgnoreCase(dataType)) {
return !isApiEndpoint;
}
// Default to including all if unrecognized type
return true;
}
/**
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
* /api/v1/auth/* paths.
*
* @param endpoint The endpoint path to check
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
*/
private boolean isApiEndpoint(String endpoint) {
if (endpoint == null) {
return false;
}
// Check if it starts with /api/v1/
if (!endpoint.startsWith("/api/v1/")) {
return false;
}
// Exclude auth endpoints
if (endpoint.startsWith("/api/v1/auth/")) {
return false;
}
return true;
}
// DTOs for response formatting
@lombok.Data

View File

@ -298,6 +298,20 @@
"general": {
"title": "General",
"description": "Configure general application preferences.",
"account": "Account",
"accountDescription": "Manage your account settings",
"user": "User",
"signedInAs": "Signed in as",
"logout": "Log out",
"enableFeatures": {
"title": "For System Administrators",
"intro": "Enable user authentication, team management, and workspace features for your organization.",
"action": "Configure",
"and": "and",
"benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.",
"learnMore": "Learn more in documentation",
"dismiss": "Dismiss"
},
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
@ -399,8 +413,10 @@
"top20": "Top 20",
"all": "All",
"refresh": "Refresh",
"includeHomepage": "Include Homepage ('/')",
"includeLoginPage": "Include Login Page ('/login')",
"dataTypeLabel": "Data Type:",
"dataTypeAll": "All",
"dataTypeApi": "API",
"dataTypeUi": "UI",
"totalEndpoints": "Total Endpoints",
"totalVisits": "Total Visits",
"showing": "Showing",
@ -3524,8 +3540,8 @@
"restartingMessage": "The server is restarting. Please wait a moment...",
"restartError": "Failed to restart server. Please restart manually.",
"general": {
"title": "General",
"description": "Configure general application settings including branding and default behaviour.",
"title": "System Settings",
"description": "Configure system-wide application settings including branding and default behaviour.",
"ui": "User Interface",
"system": "System",
"appName": "Application Name",
@ -4812,8 +4828,12 @@
"top20": "Top 20",
"all": "All",
"refresh": "Refresh",
"includeHome": "Include Homepage ('/')",
"includeLogin": "Include Login Page ('/login')"
"dataTypeLabel": "Data Type:",
"dataType": {
"all": "All",
"api": "API",
"ui": "UI"
}
},
"showing": {
"top10": "Top 10",

View File

@ -18,7 +18,7 @@ interface AppConfigModalProps {
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const navigate = useNavigate();
const location = useLocation();
const [active, setActive] = useState<NavKey>('overview');
const [active, setActive] = useState<NavKey>('general');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
@ -27,7 +27,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const match = pathname.match(/\/settings\/([^/]+)/);
if (match && match[1]) {
const validSections: NavKey[] = [
'overview', 'people', 'teams', 'general', 'hotkeys',
'people', 'teams', 'general', 'hotkeys',
'adminGeneral', 'adminSecurity', 'adminConnections', 'adminLegal',
'adminPrivacy', 'adminDatabase', 'adminPremium', 'adminFeatures',
'adminPlan', 'adminAudit', 'adminUsage', 'adminEndpoints', 'adminAdvanced'
@ -44,8 +44,8 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
if (opened && section) {
setActive(section);
} else if (opened && location.pathname.startsWith('/settings') && !section) {
// If at /settings without a section, redirect to overview
navigate('/settings/overview', { replace: true });
// If at /settings without a section, redirect to general
navigate('/settings/general', { replace: true });
}
}, [location.pathname, opened, navigate]);
@ -81,6 +81,8 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(

View File

@ -2,8 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
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';
@ -49,34 +47,6 @@ export const createConfigNavSections = (
runningEE: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
},
{
title: 'Preferences',
items: [
@ -104,7 +74,7 @@ export const createConfigNavSections = (
items: [
{
key: 'adminGeneral',
label: 'General',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},

View File

@ -165,9 +165,9 @@ export default function AdminGeneralSection() {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'General')}</Text>
<Text fw={600} size="lg">{t('admin.settings.general.title', 'System Settings')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.general.description', 'Configure general application settings including branding and default behaviour.')}
{t('admin.settings.general.description', 'Configure system-wide application settings including branding and default behaviour.')}
</Text>
</div>

View File

@ -8,7 +8,6 @@ import {
Loader,
Alert,
Card,
Checkbox,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
@ -22,8 +21,7 @@ const AdminUsageSection: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
const [includeHome, setIncludeHome] = useState(true);
const [includeLogin, setIncludeLogin] = useState(true);
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('all');
const fetchData = async () => {
try {
@ -31,11 +29,7 @@ const AdminUsageSection: React.FC = () => {
setError(null);
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(
limit,
includeHome,
includeLogin
);
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
setData(response);
} catch (err) {
@ -47,7 +41,7 @@ const AdminUsageSection: React.FC = () => {
useEffect(() => {
fetchData();
}, [displayMode, includeHome, includeLogin]);
}, [displayMode, dataType]);
const handleRefresh = () => {
fetchData();
@ -136,15 +130,26 @@ const AdminUsageSection: React.FC = () => {
</Group>
<Group>
<Checkbox
label={t('usage.controls.includeHome', "Include Homepage ('/')")}
checked={includeHome}
onChange={(event) => setIncludeHome(event.currentTarget.checked)}
/>
<Checkbox
label={t('usage.controls.includeLogin', "Include Login Page ('/login')")}
checked={includeLogin}
onChange={(event) => setIncludeLogin(event.currentTarget.checked)}
<Text size="sm" fw={500}>
{t('usage.controls.dataTypeLabel', 'Data Type:')}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
data={[
{
value: 'all',
label: t('usage.controls.dataType.all', 'All'),
},
{
value: 'api',
label: t('usage.controls.dataType.api', 'API'),
},
{
value: 'ui',
label: t('usage.controls.dataType.ui', 'UI'),
},
]}
/>
</Group>

View File

@ -1,29 +1,98 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Alert, Code, Group, Anchor, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import LocalIcon from '@app/components/shared/LocalIcon';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
const GeneralSection: React.FC = () => {
interface GeneralSectionProps {
hideTitle?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
const [bannerDismissed, setBannerDismissed] = useState(() => {
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
});
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
const handleDismissBanner = () => {
setBannerDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
{!hideTitle && (
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
)}
{loginDisabled && !bannerDismissed && (
<Paper withBorder p="md" radius="md" style={{ background: 'var(--mantine-color-blue-0)', position: 'relative' }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
onClick={handleDismissBanner}
aria-label={t('settings.general.enableFeatures.dismiss', 'Dismiss')}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
<Stack gap="sm">
<Group gap="xs">
<LocalIcon icon="admin-panel-settings-rounded" width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)' }} />
<Text fw={600} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{t('settings.general.enableFeatures.title', 'For System Administrators')}
</Text>
</Group>
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.intro', 'Enable user authentication, team management, and workspace features for your organization.')}
</Text>
<Group gap="xs" wrap="wrap">
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.action', 'Configure')}
</Text>
<Code>SECURITY_ENABLELOGIN=true</Code>
<Text size="sm" c="dimmed">
{t('settings.general.enableFeatures.and', 'and')}
</Text>
<Code>DISABLE_ADDITIONAL_FEATURES=false</Code>
</Group>
<Text size="xs" c="dimmed" fs="italic">
{t('settings.general.enableFeatures.benefit', 'Enables user roles, team collaboration, admin controls, and enterprise features.')}
</Text>
<Anchor
href="https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security"
target="_blank"
size="sm"
style={{ color: 'var(--mantine-color-blue-6)' }}
>
{t('settings.general.enableFeatures.learnMore', 'Learn more in documentation')}
</Anchor>
</Stack>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">

View File

@ -1,5 +1,4 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'

View File

@ -23,18 +23,18 @@ const usageAnalyticsService = {
*/
async getEndpointStatistics(
limit?: number,
includeHome: boolean = true,
includeLogin: boolean = true
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<EndpointStatisticsResponse> {
const params: Record<string, any> = {
includeHome,
includeLogin,
};
const params: Record<string, any> = {};
if (limit !== undefined) {
params.limit = limit;
}
if (dataType !== 'all') {
params.dataType = dataType;
}
const response = await apiClient.get<EndpointStatisticsResponse>(
'/api/v1/proprietary/ui-data/usage-endpoint-statistics',
{ params }
@ -47,10 +47,9 @@ const usageAnalyticsService = {
*/
async getChartData(
limit?: number,
includeHome: boolean = true,
includeLogin: boolean = true
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<UsageChartData> {
const stats = await this.getEndpointStatistics(limit, includeHome, includeLogin);
const stats = await this.getEndpointStatistics(limit, dataType);
return {
labels: stats.endpoints.map((e) => e.endpoint),

View File

@ -0,0 +1,44 @@
import React from 'react';
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@proprietary/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@proprietary/components/shared/config/configSections/TeamsSection';
/**
* Proprietary extension of createConfigNavSections that adds workspace sections
*/
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void,
isAdmin: boolean = false,
runningEE: boolean = false
): ConfigNavSection[] => {
// Get the core sections
const sections = createCoreConfigNavSections(Overview, onLogoutClick, isAdmin, runningEE);
// Add Workspace section after Preferences (index 1)
const workspaceSection: ConfigNavSection = {
title: 'Workspace',
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
component: <PeopleSection />
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
component: <TeamsSection />
},
],
};
// Insert workspace section after Preferences (at index 1)
sections.splice(1, 0, workspaceSection);
return sections;
};
// Re-export types for convenience
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';

View File

@ -0,0 +1,53 @@
import React from 'react';
import { Paper, Stack, Text, Button, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
/**
* Proprietary extension of GeneralSection that adds account management
*/
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
};
return (
<Stack gap="lg">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
{user && (
<Stack gap="xs" align="flex-end">
<Text size="sm" c="dimmed">
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
</Text>
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
</Button>
</Stack>
)}
</div>
{/* Render core general section preferences (without title since we show it above) */}
<CoreGeneralSection hideTitle />
</Stack>
);
};
export default GeneralSection;

View File

@ -23,7 +23,7 @@ import {
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 { teamService, Team } from '@proprietary/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';

View File

@ -18,7 +18,7 @@ import {
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { teamService, Team } from '@proprietary/services/teamService';
import { User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';

View File

@ -19,10 +19,10 @@ import {
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { teamService, Team } from '@proprietary/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 TeamDetailsSection from '@proprietary/components/shared/config/configSections/TeamDetailsSection';
export default function TeamsSection() {
const { t } = useTranslation();