Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions frontend/editor/src/core/api/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import apiClient from "@app/services/apiClient";

export interface FooterInfo {
analyticsEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}

/** Public — no authentication required. */
export async function fetchFooterInfo(): Promise<FooterInfo> {
try {
const response = await apiClient.get<FooterInfo>(
"/api/v1/ui-data/footer-info",
{ suppressErrorToast: true },
);
return response.data;
} catch (error) {
// Toasts are suppressed here, so the failure would otherwise be silent.
console.error("[api/config] footer-info failed:", error);
throw error;
}
}

export async function fetchGroupEnabled(group: string): Promise<boolean> {
const response = await apiClient.get<boolean>(
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
);
return response.data;
}
8 changes: 8 additions & 0 deletions frontend/editor/src/core/api/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import apiClient from "@app/services/apiClient";
import { UserSummary } from "@app/types/signingSession";

export async function fetchUsers(): Promise<UserSummary[]> {
const response = await apiClient.get<UserSummary[]>("/api/v1/user/users");
// A proxy can answer 200 with an HTML login page; callers assume an array.
return Array.isArray(response.data) ? response.data : [];
}
133 changes: 69 additions & 64 deletions frontend/editor/src/core/components/AppProviders.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createAppQueryClient } from "@app/query/queryClient";
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
Expand Down Expand Up @@ -119,70 +121,73 @@ export function AppProviders({
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
const [queryClient] = useState(createAppQueryClient);
return (
<PreferencesProvider>
<ThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<PosthogTrackingInitializer />
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
No-ops inside Tauri — the desktop popup handles that flow. */}
<UpdateStartupPopup />
<FileContextProvider
enableUrlSync={true}
enablePersistence={true}
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<ThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<FolderProvider>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<SigningOverlayProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SigningOverlayProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FolderProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
</ErrorBoundary>
</ThemeProvider>
</PreferencesProvider>
<PosthogTrackingInitializer />
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
No-ops inside Tauri — the desktop popup handles that flow. */}
<UpdateStartupPopup />
<FileContextProvider
enableUrlSync={true}
enablePersistence={true}
>
<FolderProvider>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<SigningOverlayProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
<FolderFileContextProvider>
{children}
</FolderFileContextProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SigningOverlayProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FolderProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
</ErrorBoundary>
</ThemeProvider>
</PreferencesProvider>
</QueryClientProvider>
);
}
106 changes: 43 additions & 63 deletions frontend/editor/src/core/components/shared/UserSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { MultiSelect, Loader, Text, Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useNavigate } from "react-router-dom";
import { alert } from "@app/components/toast";
import { UserSummary } from "@app/types/signingSession";
import apiClient from "@app/services/apiClient";
import { fetchUsers } from "@app/api/users";
import { useAuth } from "@app/auth/UseSession";
import { qk } from "@app/query/keys";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";

interface UserSelectorProps {
Expand All @@ -30,79 +31,58 @@ const UserSelector = ({
const { t } = useTranslation();
const { user } = useAuth();
const navigate = useNavigate();
const [selectData, setSelectData] = useState<GroupedData[]>([]);
const [loading, setLoading] = useState(true);
const [stringValue, setStringValue] = useState<string[]>([]);

useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiClient.get("/api/v1/user/users");
console.log("Users API response:", response.data);
const fetchedUsers = response.data || [];

// Process selectData inside useEffect - group by team
const usersByTeam: Record<string, SelectItem[]> = {};
const currentUserId = user?.id ? parseInt(user.id, 10) : null;
const {
data: users,
isPending: loading,
error,
} = useQuery({ queryKey: qk.users(), queryFn: fetchUsers });

fetchedUsers
.filter((u: UserSummary) => u && u.userId && u.username)
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== "internal") // Exclude internal users
.forEach((user: UserSummary) => {
const teamName =
user.teamName ||
t("certSign.collab.userSelector.noTeam", "No Team");
if (!usersByTeam[teamName]) {
usersByTeam[teamName] = [];
}
const displayName = user.displayName || user.username || "Unknown";
const username = user.username || "unknown";
const label =
displayName !== username
? `${displayName} (@${username})`
: displayName;
usersByTeam[teamName].push({
value: String(user.userId),
label,
});
});
useEffect(() => {
if (!error) return;
alert({
alertType: "error",
title: t("common.error"),
body: t("certSign.collab.userSelector.loadError", "Failed to load users"),
});
}, [error, t]);

// Convert to Mantine's grouped format
const processed: GroupedData[] = Object.entries(usersByTeam).map(
([teamName, items]) => ({
group: teamName,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}),
);
const selectData = useMemo<GroupedData[]>(() => {
const usersByTeam: Record<string, SelectItem[]> = {};
const currentUserId = user?.id ? parseInt(user.id, 10) : null;

console.log("Processed selectData:", processed);
setSelectData(processed);
} catch (error) {
console.error("Failed to load users:", error);
alert({
alertType: "error",
title: t("common.error"),
body: t(
"certSign.collab.userSelector.loadError",
"Failed to load users",
),
});
} finally {
setLoading(false);
}
};
(users ?? [])
.filter((u) => u && u.userId && u.username)
.filter((u) => u.userId !== currentUserId)
.filter((u) => u.teamName?.toLowerCase() !== "internal")
.forEach((u) => {
const teamName =
u.teamName || t("certSign.collab.userSelector.noTeam", "No Team");
if (!usersByTeam[teamName]) {
usersByTeam[teamName] = [];
}
const displayName = u.displayName || u.username || "Unknown";
const username = u.username || "unknown";
const label =
displayName !== username
? `${displayName} (@${username})`
: displayName;
usersByTeam[teamName].push({ value: String(u.userId), label });
});

fetchUsers();
}, [t, user]);
return Object.entries(usersByTeam).map(([teamName, items]) => ({
group: teamName,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}));
}, [users, user, t]);

// Process stringValue when value prop changes
useEffect(() => {
const safeValue = Array.isArray(value) ? value : [];
const result = safeValue
.map((id) => (id != null ? id.toString() : ""))
.filter(Boolean);
console.log("stringValue for MultiSelect:", result);
setStringValue(result);
}, [value]);

Expand Down
56 changes: 56 additions & 0 deletions frontend/editor/src/core/hooks/useFooterInfo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
import { useFooterInfo } from "@app/hooks/useFooterInfo";
import { fetchFooterInfo } from "@app/api/config";

vi.mock("@app/api/config", () => ({ fetchFooterInfo: vi.fn() }));

const mockFetch = vi.mocked(fetchFooterInfo);

describe("useFooterInfo", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns the server's footer config", async () => {
mockFetch.mockResolvedValue({
analyticsEnabled: true,
privacyPolicy: "/privacy",
});

const { result } = renderHook(() => useFooterInfo(), {
wrapper: TestQueryProvider,
});

expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.footerInfo).toEqual({
analyticsEnabled: true,
privacyPolicy: "/privacy",
});
});

it("falls back to analytics-off rather than null when the fetch fails", async () => {
mockFetch.mockRejectedValue(new Error("offline"));

const { result } = renderHook(() => useFooterInfo(), {
wrapper: TestQueryProvider,
});

await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.footerInfo).toEqual({ analyticsEnabled: false });
});

it("shares one request between the footer and the legal section", async () => {
mockFetch.mockResolvedValue({ analyticsEnabled: false });

const { result } = renderHook(
() => ({ footer: useFooterInfo(), legal: useFooterInfo() }),
{ wrapper: TestQueryProvider },
);

await waitFor(() => expect(result.current.footer.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
Loading
Loading