Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
+90
View File
@@ -0,0 +1,90 @@
import { Suspense } from "react";
import { Routes, Route, useParams } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import ShareLinkPage from "@app/routes/ShareLinkPage";
import ParticipantView from "@app/components/workflow/ParticipantView";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
import "@app/styles/index.css";
import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
// Participant signing page — token-gated, no login required
function ParticipantViewPage() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
return <ParticipantView token={token} />;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* Participant signing — public, token-gated, no auth required */}
<Route
path="/workflow/sign/:token"
element={
<MobileScannerProviders>
<ParticipantViewPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Route path="/share/:token" element={<ShareLinkPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
+51
View File
@@ -0,0 +1,51 @@
Stirling PDF User License
Copyright (c) 2025 Stirling PDF Inc.
License Scope & Usage Rights
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
covering the appropriate number of licensed users.
Trial and Minimal Use
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
* Use is limited to the capabilities and restrictions defined by the Software itself;
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
Continued use beyond this scope requires a valid Stirling PDF User License.
Modifications and Derivative Works
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
* May not be deployed in production environments without a valid User License;
* May not be distributed or sublicensed;
* Remain the intellectual property of Stirling PDF and/or its licensors;
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
Prohibited Actions
Unless explicitly permitted by a paid license or separate agreement, you may not:
* Use the Software in production environments;
* Copy, merge, distribute, sublicense, or sell the Software;
* Remove or alter any licensing or copyright notices;
* Circumvent access restrictions or licensing requirements.
Third-Party Components
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
their original license terms as provided by their respective owners.
Disclaimer
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,296 @@
import {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
} from "react";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import type {
Session,
User,
AuthError,
AuthChangeEvent,
} from "@app/auth/springAuthClient";
/**
* Auth Context Type
* Simplified version without SaaS-specific features (credits, subscriptions)
*/
interface AuthContextType {
session: Session | null;
user: User | null;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
signOut: async () => {},
refreshSession: async () => {},
});
/**
* Auth Provider Component
*
* Manages authentication state and provides it to the entire app.
* Integrates with Spring Security + JWT backend.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
// Debug: Track state transitions
useEffect(() => {
console.log("[Auth] State changed:", {
loading,
hasSession: !!session,
hasError: !!error,
userId: session?.user?.id,
timestamp: new Date().toISOString(),
});
}, [loading, session, error]);
/**
* Refresh current session
*/
const refreshSession = useCallback(async () => {
try {
setLoading(true);
setError(null);
console.debug("[Auth] refreshSession: start", {
path: window.location.pathname,
});
console.debug("[Auth] Refreshing session...");
const { data, error } = await springAuth.refreshSession();
if (error) {
console.error("[Auth] Session refresh error:", error);
setError(error);
setSession(null);
} else {
console.debug("[Auth] Session refreshed successfully");
setSession(data.session);
}
} catch (err) {
console.error("[Auth] Unexpected error during session refresh:", err);
setError(err as AuthError);
} finally {
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
setLoading(false);
}
}, []);
/**
* Sign out user
*/
const signOut = useCallback(async () => {
try {
setError(null);
console.debug("[Auth] Signing out...");
const { error } = await springAuth.signOut();
if (error) {
console.error("[Auth] Sign out error:", error);
setError(error);
} else {
console.debug("[Auth] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error("[Auth] Unexpected error during sign out:", err);
setError(err as AuthError);
}
}, []);
/**
* Initialize auth on mount
*/
useEffect(() => {
let mounted = true;
const mountId = Math.random().toString(36).substring(7);
console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`);
const initializeAuth = async () => {
try {
console.debug(`[Auth:${mountId}] Initializing auth...`);
console.debug(
`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`,
);
// Clear any platform-specific cached auth on login page init.
if (
typeof window !== "undefined" &&
window.location.pathname.startsWith("/login")
) {
await clearPlatformAuthOnLoginInit();
}
// Skip config check entirely - let the app handle login state
// The config will be fetched by useAppConfig when needed
const { data, error } = await springAuth.getSession();
if (!mounted) return;
if (error) {
console.error("[Auth] Initial session error:", error);
setError(error);
} else {
console.debug("[Auth] Initial session loaded:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
});
setSession(data.session);
}
} catch (err) {
console.error(
"[Auth] Unexpected error during auth initialization:",
err,
);
if (mounted) {
setError(err as AuthError);
}
} finally {
console.debug(
`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`,
);
if (mounted) {
setLoading(false);
}
}
};
initializeAuth();
// Listen for jwt-available event (triggered by desktop auth or other sources)
const handleJwtAvailable = () => {
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 🔄 JWT available event received`);
console.log(
`[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`,
);
console.log(
`[Auth:${mountId}] Setting loading=true to stabilize auth state`,
);
setLoading(true); // Prevent unstable renders during auth state transition
setError(null);
console.log(`[Auth:${mountId}] Refreshing session...`);
void initializeAuth();
};
window.addEventListener("jwt-available", handleJwtAvailable);
// Subscribe to auth state changes
const {
data: { subscription },
} = springAuth.onAuthStateChange(
async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(
`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`,
);
return;
}
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
console.log(
`[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`,
);
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
// Schedule state update
setTimeout(() => {
if (mounted) {
console.log(
`[Auth:${mountId}] Applying session update (event: ${event})`,
);
setSession(newSession);
setError(null);
// Handle specific events
if (event === "SIGNED_OUT") {
console.log(
`[Auth:${mountId}] ✓ User signed out, session cleared`,
);
} else if (event === "SIGNED_IN") {
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
} else if (event === "TOKEN_REFRESHED") {
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
} else if (event === "USER_UPDATED") {
console.log(`[Auth:${mountId}] ✓ User updated`);
}
} else {
console.log(
`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`,
);
}
}, 0);
},
);
return () => {
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
mounted = false;
window.removeEventListener("jwt-available", handleJwtAvailable);
subscription.unsubscribe();
};
}, []);
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
signOut,
refreshSession,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Hook to access auth context
* Must be used within AuthProvider
*/
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
/**
* Debug hook to expose auth state for debugging
* Can be used in development to monitor auth state
*/
export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug("[Auth Debug] Current auth state:", {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
});
}, [auth.session, auth.user, auth.loading, auth.error]);
return auth;
}
@@ -0,0 +1,34 @@
/**
* Helper utilities for clearing cached OAuth redirect/session state
*/
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
/**
* Clear any persisted OAuth redirect path/cached state so the app
* does not automatically resume a previous OAuth session after logout.
*/
export function resetOAuthState(): void {
try {
// Remove redirect cookie
if (typeof document !== "undefined") {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
} catch (err) {
console.warn("[OAuthStorage] Failed to clear redirect cookie", err);
}
// Remove any related localStorage entries we might have used
try {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
window.localStorage.removeItem("oauth_redirect_path");
}
} catch (err) {
console.warn("[OAuthStorage] Failed to clear OAuth localStorage", err);
}
}
export default {
resetOAuthState,
};
@@ -0,0 +1,24 @@
/**
* Known OAuth providers with dedicated UI support.
* Custom providers are also supported - the backend determines availability.
*/
export const KNOWN_OAUTH_PROVIDERS = [
"github",
"google",
"apple",
"azure",
"keycloak",
"cloudron",
"authentik",
"oidc",
] as const;
export type KnownOAuthProvider = (typeof KNOWN_OAUTH_PROVIDERS)[number];
/**
* OAuth provider ID - can be any known provider or custom string.
* The backend configuration determines which providers are available.
*
* @example 'github' | 'google' | 'mycompany' | 'authentik'
*/
export type OAuthProvider = KnownOAuthProvider | (string & {});
@@ -0,0 +1,482 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
consumePostLoginRedirectPath,
isSafePostLoginRedirect,
POST_LOGIN_REDIRECT_STORAGE_KEY,
setPostLoginRedirectPath,
springAuth,
} from "@app/auth/springAuthClient";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
import apiClient from "@app/services/apiClient";
import {
AxiosError,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios";
// Mock apiClient
vi.mock("@app/services/apiClient");
vi.mock("@app/extensions/oauthNavigation", () => ({
startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));
describe("SpringAuthClient", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
// Clear all mocks
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("getSession", () => {
it("should return null session when no JWT in localStorage", async () => {
const result = await springAuth.getSession();
expect(result.data.session).toBeNull();
expect(result.error).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it("should validate JWT and return session when JWT exists", async () => {
const mockToken = "mock-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
} as unknown as AxiosResponse);
const result = await springAuth.getSession();
expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
skipAuthRedirect: true,
});
expect(result.data.session).toBeTruthy();
expect(result.data.session?.user).toEqual(mockUser);
expect(result.data.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it("should clear invalid JWT on 401 error", async () => {
const mockToken = "invalid-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
const mockError = new AxiosError(
"Unauthorized",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 401,
statusText: "Unauthorized",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
},
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 401 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
it("should clear invalid JWT on 403 error", async () => {
const mockToken = "forbidden-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
const mockError = new AxiosError(
"Forbidden",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 403,
statusText: "Forbidden",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
},
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 403 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
});
describe("signInWithPassword", () => {
it("should successfully sign in with email and password", async () => {
const credentials = {
email: "[email protected]",
password: "password123",
};
const mockToken = "new-jwt-token";
const mockUser = {
id: "123",
email: credentials.email,
username: credentials.email,
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: mockToken,
expires_in: 3600,
},
},
} as unknown as AxiosResponse);
// Spy on window.dispatchEvent
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
const result = await springAuth.signInWithPassword(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true },
);
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
expect(result.user).toEqual(mockUser);
expect(result.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it("should return error on failed login", async () => {
const credentials = {
email: "[email protected]",
password: "wrongpassword",
};
const errorMessage = "Invalid credentials";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 401,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signInWithPassword(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe("signUp", () => {
it("should successfully register new user", async () => {
const credentials = {
email: "[email protected]",
password: "newpassword123",
};
const mockUser = {
id: "456",
email: credentials.email,
username: credentials.email,
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
} as unknown as AxiosResponse);
const result = await springAuth.signUp(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true },
);
expect(result.user).toEqual(mockUser);
expect(result.session).toBeNull(); // No auto-login on signup
expect(result.error).toBeNull();
});
it("should return error on failed registration", async () => {
const credentials = {
email: "[email protected]",
password: "password123",
};
const errorMessage = "User already exists";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 409,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signUp(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe("signOut", () => {
it("should successfully sign out and clear JWT", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {},
} as unknown as AxiosResponse);
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
"/api/v1/auth/logout",
null,
expect.objectContaining({ withCredentials: true }),
);
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.error).toBeNull();
});
it("should clear JWT even if logout request fails", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: "Server error",
});
const result = await springAuth.signOut();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe("refreshSession", () => {
it("should refresh JWT token successfully", async () => {
const newToken = "refreshed-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: newToken,
expires_in: 3600,
},
},
} as unknown as AxiosResponse);
const result = await springAuth.refreshSession();
expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
it("should clear JWT and return error on 401", async () => {
localStorage.setItem("stirling_jwt", "expired-token");
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: "Token expired",
});
const result = await springAuth.refreshSession();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe("signInWithOAuth", () => {
it("should redirect to OAuth provider", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
const result = await springAuth.signInWithOAuth({
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(result.error).toBeNull();
});
it("should skip redirect when handled by extension", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
const result = await springAuth.signInWithOAuth({
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
});
describe("post-login redirect path", () => {
beforeEach(() => {
sessionStorage.clear();
});
describe("isSafePostLoginRedirect", () => {
it("accepts same-origin paths with a single leading slash", () => {
expect(isSafePostLoginRedirect("/share/abc123")).toBe(true);
expect(isSafePostLoginRedirect("/workbench")).toBe(true);
expect(isSafePostLoginRedirect("/share/abc?x=1")).toBe(true);
});
it("rejects empty, null, or non-string values", () => {
expect(isSafePostLoginRedirect("")).toBe(false);
expect(isSafePostLoginRedirect(null)).toBe(false);
expect(isSafePostLoginRedirect(undefined)).toBe(false);
expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
});
it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => {
expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
expect(isSafePostLoginRedirect("http://evil.example")).toBe(false);
expect(isSafePostLoginRedirect("https://evil.example/x")).toBe(false);
expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
});
it("rejects auth-plumbing paths to avoid login loops", () => {
expect(isSafePostLoginRedirect("/login")).toBe(false);
expect(isSafePostLoginRedirect("/login?foo=1")).toBe(false);
expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(
false,
);
expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
});
});
describe("setPostLoginRedirectPath", () => {
it("stores a safe path in sessionStorage", () => {
setPostLoginRedirectPath("/share/abc123");
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBe(
"/share/abc123",
);
});
it("clears any existing entry when given an unsafe value", () => {
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
setPostLoginRedirectPath("//evil.example");
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("clears any existing entry when given null", () => {
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
setPostLoginRedirectPath(null);
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
});
describe("consumePostLoginRedirectPath", () => {
it("returns the stored path and clears it (single-use)", () => {
sessionStorage.setItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
"/share/abc123",
);
expect(consumePostLoginRedirectPath()).toBe("/share/abc123");
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("returns null (and still clears) when the stored value is unsafe", () => {
sessionStorage.setItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
"//evil.example",
);
expect(consumePostLoginRedirectPath()).toBeNull();
expect(
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
).toBeNull();
});
it("returns null when nothing is stored", () => {
expect(consumePostLoginRedirectPath()).toBeNull();
});
});
});
});
@@ -0,0 +1,874 @@
/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
*/
import apiClient from "@app/services/apiClient";
import { AxiosError } from "axios";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { resetOAuthState } from "@app/auth/oauthStorage";
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
import {
getPlatformSessionUser,
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === "object" && "response" in error) {
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === "number") {
return response.status;
}
}
return undefined;
}
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
return (
error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
fallback
);
}
return error instanceof Error ? error.message : fallback;
}
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
export const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== "string") {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || "/";
const query = parsed.search || "";
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
} catch (_error) {
// console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error);
}
}
// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
// URLs to guard against open-redirect abuse if the stored value is tampered with.
export function isSafePostLoginRedirect(path: unknown): path is string {
if (typeof path !== "string" || path.length === 0) return false;
if (!path.startsWith("/") || path.startsWith("//")) return false;
if (path.startsWith("/\\")) return false;
const lowered = path.toLowerCase();
if (
lowered.startsWith("/login") ||
lowered.startsWith("/auth/") ||
lowered.startsWith("/oauth2") ||
lowered.startsWith("/saml2")
) {
return false;
}
return true;
}
export function setPostLoginRedirectPath(
path: string | null | undefined,
): void {
try {
if (typeof window === "undefined") return;
if (isSafePostLoginRedirect(path)) {
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
} else {
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
}
} catch (_error) {
// sessionStorage unavailable (private mode) — fail open
}
}
export function consumePostLoginRedirectPath(): string | null {
try {
if (typeof window === "undefined") return null;
const value = window.sessionStorage.getItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
);
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
return isSafePostLoginRedirect(value) ? value : null;
} catch (_error) {
return null;
}
}
// Auth types
export interface User {
id: string;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
app_metadata?: Record<string, unknown>;
}
export interface Session {
user: User;
access_token: string;
expires_in: number;
expires_at?: number;
}
export interface AuthError {
message: string;
status?: number;
code?: string;
mfaRequired?: boolean;
}
export interface AuthResponse {
user: User | null;
session: Session | null;
error: AuthError | null;
}
export type AuthChangeEvent =
| "SIGNED_IN"
| "SIGNED_OUT"
| "TOKEN_REFRESHED"
| "USER_UPDATED";
type AuthChangeCallback = (
event: AuthChangeEvent,
session: Session | null,
) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
// Adaptive intervals - calculated based on actual JWT token lifetime
// Defaults for initial startup (will be recalculated on first token)
private sessionCheckIntervalMs = 10000; // 10 seconds default
private tokenRefreshThresholdMs = 30000; // 30 seconds default
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Calculate optimal check interval and refresh threshold based on token lifetime.
* - Check interval: token lifetime / 6 (check 6 times during token life)
* - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
* - Applies min/max bounds for sanity
*/
private calculateAdaptiveIntervals(token: string): void {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
console.warn(
"[SpringAuth] Cannot decode token for adaptive intervals, using defaults",
);
return;
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
if (expSeconds <= 0 || iatSeconds <= 0) {
console.warn(
"[SpringAuth] Token missing exp/iat claims, using default intervals",
);
return;
}
const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
// Check interval: check 6 times during token lifetime
// Min: 5 seconds (for very short tokens)
// Max: 60 seconds (don't check too infrequently)
this.sessionCheckIntervalMs = Math.max(
5000,
Math.min(60000, tokenLifetimeMs / 6),
);
// Refresh threshold: refresh when 25% of lifetime remaining
// Min: 30 seconds (give buffer for refresh to complete)
// Max: 5 minutes (don't wait too long for long-lived tokens)
this.tokenRefreshThresholdMs = Math.max(
30000,
Math.min(300000, tokenLifetimeMs / 4),
);
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + "s",
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + "s",
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn(
"[SpringAuth] Failed to calculate adaptive intervals:",
error,
);
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
return JSON.parse(atob(base64));
}
private getTokenExpiry(token: string): {
expiresIn: number;
expiresAt: number;
} {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error("Token payload missing");
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const expiresAt =
expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
const expiresIn = Math.max(
0,
Math.floor((expiresAt - Date.now()) / 1000),
);
return { expiresIn, expiresAt };
} catch {
// Fallback for non-JWT or malformed tokens.
const expiresAt = Date.now() + 3600 * 1000;
return { expiresIn: 3600, expiresAt };
}
}
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === "XSRF-TOKEN") {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
try {
// Get JWT from localStorage
let token = localStorage.getItem("stirling_jwt");
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
if (await isDesktopSaaSAuthMode()) {
let tokenExpiry = this.getTokenExpiry(token);
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem("stirling_jwt");
if (!refreshedToken) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
token = refreshedToken;
tokenExpiry = this.getTokenExpiry(token);
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const platformUser = await getPlatformSessionUser();
const session: Session = {
user: {
id:
platformUser?.email ||
platformUser?.username ||
"desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
role: "USER",
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
return { data: { session }, error: null };
}
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await apiClient.get("/api/v1/auth/me", {
headers: {
Authorization: `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
skipAuthRedirect: true,
});
// console.debug('[SpringAuth] /me response status:', response.status);
const data = response.data;
// console.debug('[SpringAuth] /me response data:', data);
// Create session object
const tokenExpiry = this.getTokenExpiry(token);
const session: Session = {
user: data.user,
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] getSession error:", error);
// If 401/403, token is invalid - try explicit refresh
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
// A 401 during startup can be a race with a concurrent refresh. Try one
// explicit refresh before treating the session as invalid.
const refreshResult = await this.refreshSession();
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem("stirling_jwt");
console.debug("[SpringAuth] getSession: Not authenticated");
return { data: { session: null }, error: null };
}
// Don't clear token for other errors (e.g., backend not ready, network issues)
// The token is still valid, just can't verify it right now
return {
data: { session: null },
error: { message: getErrorMessage(error, "Unknown error") },
};
}
}
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: {
email: string;
password: string;
mfaCode?: string;
}): Promise<AuthResponse> {
try {
const response = await apiClient.post(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
},
{
withCredentials: true, // Include cookies for CSRF
},
);
const data = response.data;
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners("SIGNED_IN", session);
return { user: data.user, session, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signInWithPassword error:", error);
if (error instanceof AxiosError) {
const errorCode = error.response?.data?.error as string | undefined;
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
"Login failed";
return {
user: null,
session: null,
error: {
message: errorMessage,
status: error.response?.status,
code: errorCode,
mfaRequired: errorCode === "mfa_required",
},
};
}
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Login failed") },
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await apiClient.post(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{
withCredentials: true,
},
);
const data = response.data;
// Note: Spring backend auto-confirms users (no email verification)
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signUp error:", error);
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Registration failed") },
};
}
}
/**
* Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2/SAML2 authorization endpoint
*
* @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
* The backend provides the complete path including the auth type and provider ID
*/
async signInWithOAuth(params: {
provider: OAuthProvider;
options?: { redirectTo?: string; queryParams?: Record<string, string> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
const redirectUrl = params.provider;
const handled = await startOAuthNavigation(redirectUrl);
if (handled) {
return { error: null };
}
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
error: {
message:
error instanceof Error ? error.message : "SSO redirect failed",
},
};
}
}
/**
* Sign out user (invalidate session)
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== "undefined") {
window.sessionStorage.setItem(
"stirling_sso_auto_login_logged_out",
"1",
);
}
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
// Clean up local storage
localStorage.removeItem("stirling_jwt");
try {
Object.keys(localStorage)
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn(
"[SpringAuth] Failed to clear Supabase/local auth tokens",
err,
);
}
// Clear cookies that might hold refresh/session tokens
try {
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name =
eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
if (name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
}
});
} catch (err) {
console.warn("[SpringAuth] Failed to clear cookies on sign out", err);
}
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn(
"[SpringAuth] Failed to run platform auth cleanup",
cleanupError,
);
}
// Notify listeners
this.notifyListeners("SIGNED_OUT", null);
return { error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signOut error:", error);
// Still remove token even if backend call fails
localStorage.removeItem("stirling_jwt");
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn(
"[SpringAuth] Failed to run platform auth cleanup after error",
cleanupError,
);
}
return {
error: { message: getErrorMessage(error, "Logout failed") },
};
}
}
/**
* Refresh JWT token
*/
async refreshSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
try {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return {
data: { session: null },
error: { message: "Token refresh failed - please log in again" },
};
}
const { data, error } = await this.getSession();
if (error || !data.session) {
return {
data: { session: null },
error: error || {
message: "Token refresh failed - please log in again",
},
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem("stirling_jwt");
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners("TOKEN_REFRESHED", data.session);
return { data, error: null };
}
const response = await apiClient.post("/api/v1/auth/refresh", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
const data = response.data;
const token = data.session.access_token;
// Update local storage with new token
localStorage.setItem("stirling_jwt", token);
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners("TOKEN_REFRESHED", session);
console.debug("[SpringAuth] Token refreshed successfully");
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] refreshSession error:", error);
localStorage.removeItem("stirling_jwt");
// Handle different error statuses
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return {
data: { session: null },
error: { message: "Token refresh failed - please log in again" },
};
}
return {
data: { session: null },
error: { message: getErrorMessage(error, "Token refresh failed") },
};
}
}
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): {
data: { subscription: { unsubscribe: () => void } };
} {
this.listeners.push(callback);
return {
data: {
subscription: {
unsubscribe: () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
},
},
},
};
}
// Private helper methods
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
// Use setTimeout to avoid calling callbacks synchronously
setTimeout(() => {
this.listeners.forEach((callback) => {
try {
callback(event, session);
} catch (error) {
console.error(
"[SpringAuth] Error in auth state change listener:",
error,
);
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
// Interval is adaptive based on token lifetime (calculated when token is received)
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon (threshold is adaptive)
if (
timeUntilExpiry > 0 &&
timeUntilExpiry < this.tokenRefreshThresholdMs
) {
console.log(
"[SpringAuth] 🔄 Proactively refreshing token (expires in " +
Math.floor(timeUntilExpiry / 1000) +
"s)",
);
await this.refreshSession();
}
}
} catch (error) {
console.error("[SpringAuth] Session monitoring error:", error);
}
}, this.sessionCheckIntervalMs);
}
private restartSessionMonitoring() {
// Stop existing interval
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
this.sessionCheckInterval = null;
}
// Start with new interval
this.startSessionMonitoring();
}
public destroy() {
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
}
}
}
export const springAuth = new SpringAuthClient();
/**
* Get current user
*/
export const getCurrentUser = async () => {
const { data } = await springAuth.getSession();
return data.session?.user || null;
};
/**
* Check if user is anonymous
*/
export const isUserAnonymous = (user: User | null) => {
return user?.is_anonymous === true;
};
/**
* Create an anonymous user object for use when login is disabled
* This provides a consistent User interface throughout the app
*/
export const createAnonymousUser = (): User => {
return {
id: "anonymous",
email: "anonymous@local",
username: "Anonymous User",
role: "USER",
enabled: true,
is_anonymous: true,
app_metadata: {
provider: "anonymous",
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: "",
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;
@@ -0,0 +1,36 @@
import {
AppProviders as CoreAppProviders,
AppProvidersProps,
} from "@core/components/AppProviders";
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return (
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
<LicenseProvider>
<UpdateSeatsProvider>
<ServerExperienceProvider>
<CheckoutProvider>
<UpgradeBannerInitializer />
{children}
</CheckoutProvider>
</ServerExperienceProvider>
</UpdateSeatsProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
);
}
@@ -0,0 +1,436 @@
import { useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Button,
Checkbox,
CloseButton,
Group,
Modal,
PasswordInput,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import {
ChangeUserPasswordRequest,
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ChangeUserPasswordModalProps {
opened: boolean;
onClose: () => void;
user: User | null;
onSuccess: () => void;
mailEnabled: boolean;
}
function generateSecurePassword() {
const charset =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
const length = 14;
let password = "";
const charsetLength = charset.length;
const uint8Array = new Uint8Array(length);
window.crypto.getRandomValues(uint8Array);
// To avoid modulo bias, discard values >= 256 - (256 % charsetLength)
for (let i = 0; password.length < length; ) {
const randomByte = uint8Array[i];
i++;
if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) {
// Discard and generate a new random value
if (i >= uint8Array.length) {
// Exhausted the array, fill a new one
window.crypto.getRandomValues(uint8Array);
i = 0;
}
continue;
}
const randomIndex = randomByte % charsetLength;
password += charset[randomIndex];
}
return password;
}
export default function ChangeUserPasswordModal({
opened,
onClose,
user,
onSuccess,
mailEnabled,
}: ChangeUserPasswordModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
const [processing, setProcessing] = useState(false);
const disabled = !user;
const handleGeneratePassword = () => {
const generated = generateSecurePassword();
setForm((prev) => ({
...prev,
newPassword: generated,
confirmPassword: generated,
generateRandom: true,
}));
};
const handleCopyPassword = async () => {
if (!form.newPassword) return;
try {
await navigator.clipboard.writeText(form.newPassword);
alert({
alertType: "success",
title: t(
"workspace.people.changePassword.copiedToClipboard",
"Password copied to clipboard",
),
});
} catch (_error) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.copyFailed",
"Failed to copy password",
),
});
}
};
const resetState = () => {
setForm({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
};
const handleClose = () => {
if (processing) return;
resetState();
onClose();
};
const handleSubmit = async () => {
if (!user) return;
if (!form.generateRandom && !form.newPassword.trim()) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.passwordRequired",
"Please enter a new password",
),
});
return;
}
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
),
});
return;
}
const payload: ChangeUserPasswordRequest = {
username: user.username,
newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true)
generateRandom: false, // Not needed since we're generating on frontend
sendEmail: form.sendEmail,
includePassword: form.includePassword,
forcePasswordChange: form.forcePasswordChange,
};
try {
setProcessing(true);
await userManagementService.changeUserPassword(payload);
alert({
alertType: "success",
title: t(
"workspace.people.changePassword.success",
"Password updated successfully",
),
});
onSuccess();
handleClose();
} catch (error: unknown) {
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.changePassword.error",
"Failed to update password",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
useEffect(() => {
if (opened) {
setForm({
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
forcePasswordChange: false,
});
}
}, [opened, user?.username]);
// Check if username is a valid email format
const isValidEmail = (email: string | undefined) => {
if (!email) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const canEmail = mailEnabled && isValidEmail(user?.username);
const passwordPreview = useMemo(
() => (form.newPassword && form.generateRandom ? form.newPassword : ""),
[form.generateRandom, form.newPassword],
);
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon
icon="lock"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.changePassword.title", "Change password")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"workspace.people.changePassword.subtitle",
"Update the password for",
)}{" "}
<strong>{user?.username}</strong>
</Text>
</Stack>
<Stack gap="sm">
<PasswordInput
label={t(
"workspace.people.changePassword.newPassword",
"New password",
)}
placeholder={t(
"workspace.people.changePassword.placeholder",
"Enter a new password",
)}
value={form.newPassword}
onChange={(event) =>
setForm({
...form,
newPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
data-autofocus
/>
<PasswordInput
label={t(
"workspace.people.changePassword.confirmPassword",
"Confirm password",
)}
placeholder={t(
"workspace.people.changePassword.confirmPlaceholder",
"Re-enter the new password",
)}
value={form.confirmPassword}
onChange={(event) =>
setForm({
...form,
confirmPassword: event.currentTarget.value,
generateRandom: false,
})
}
disabled={processing || disabled || form.generateRandom}
error={
!form.generateRandom &&
form.confirmPassword &&
form.newPassword !== form.confirmPassword
? t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
)
: undefined
}
/>
<Group justify="space-between">
<Checkbox
label={t(
"workspace.people.changePassword.generateRandom",
"Generate secure password",
)}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({ ...prev, generateRandom: checked }));
if (event.currentTarget.checked) {
handleGeneratePassword();
}
}}
/>
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.generatedPreview",
"Generated password:",
)}{" "}
<strong>{passwordPreview}</strong>
</Text>
<Tooltip
label={t(
"workspace.people.changePassword.copyTooltip",
"Copy to clipboard",
)}
>
<ActionIcon
size="sm"
variant="subtle"
color="gray"
onClick={handleCopyPassword}
disabled={processing}
>
<LocalIcon
icon="content-copy"
width="0.9rem"
height="0.9rem"
/>
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
</Stack>
<Stack gap="xs">
<Checkbox
label={t(
"workspace.people.changePassword.sendEmail",
"Email the user about this change",
)}
checked={canEmail && form.sendEmail}
onChange={(event) =>
setForm({ ...form, sendEmail: event.currentTarget.checked })
}
disabled={!canEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.includePassword",
"Include the new password in the email",
)}
checked={canEmail && form.sendEmail && form.includePassword}
onChange={(event) =>
setForm({
...form,
includePassword: event.currentTarget.checked,
})
}
disabled={!canEmail || !form.sendEmail || processing}
/>
<Checkbox
label={t(
"workspace.people.changePassword.forcePasswordChange",
"Force user to change password on next login",
)}
checked={form.forcePasswordChange}
onChange={(event) =>
setForm({
...form,
forcePasswordChange: event.currentTarget.checked,
})
}
disabled={processing || disabled}
/>
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t(
"workspace.people.changePassword.emailUnavailable",
"This user's email is not a valid email address. Notifications are disabled.",
)
: t(
"workspace.people.changePassword.smtpDisabled",
"Email notifications require SMTP to be enabled in settings.",
)}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t(
"workspace.people.changePassword.notifyOnly",
"An email will be sent without the password, letting the user know an admin changed it.",
)}
</Text>
)}
</Stack>
<Button
onClick={handleSubmit}
loading={processing}
fullWidth
size="md"
disabled={disabled}
mt="md"
>
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
</div>
</Modal>
);
}
@@ -0,0 +1,46 @@
import "@app/components/shared/dividerWithText/DividerWithText.css";
interface TextDividerProps {
text?: string;
className?: string;
style?: React.CSSProperties;
variant?: "default" | "subcategory";
respondsToDarkMode?: boolean;
opacity?: number;
}
export default function DividerWithText({
text,
className = "",
style,
variant = "default",
respondsToDarkMode = true,
opacity,
}: TextDividerProps) {
const variantClass = variant === "subcategory" ? "subcategory" : "";
const themeClass = respondsToDarkMode ? "" : "force-light";
const styleWithOpacity =
opacity !== undefined
? { ...(style || {}), ["--text-divider-opacity" as string]: opacity }
: style;
if (text) {
return (
<div
className={`text-divider ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
);
}
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
);
}
@@ -0,0 +1,870 @@
import { useState, useEffect, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Modal,
Stack,
Text,
Button,
TextInput,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Box,
Group,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService } from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useNavigate } from "react-router-dom";
interface InviteMembersModalProps {
opened: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export default function InviteMembersModal({
opened,
onClose,
onSuccess,
}: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<"email" | "direct" | "link">(
"direct",
);
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(
null,
);
const actionTakenRef = useRef(false);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
authType: "WEB" as "WEB" | "OAUTH2" | "SAML2",
forceChange: false,
forceMFA: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Fetch teams and license info
useEffect(() => {
if (opened) {
const fetchData = async () => {
try {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setTeams(teamsData);
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} catch (error) {
console.error("Failed to fetch data:", error);
}
};
fetchData();
}
}, [opened]);
const roleOptions = [
{
value: "ROLE_USER",
label: t("workspace.people.roleDescriptions.user", "User"),
},
{
value: "ROLE_ADMIN",
label: t("workspace.people.roleDescriptions.admin", "Admin"),
},
];
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
const handleInviteUser = async () => {
if (!inviteForm.username) {
alert({
alertType: "error",
title: t("workspace.people.addMember.usernameRequired"),
});
return;
}
// Password is only required for WEB auth type
if (inviteForm.authType === "WEB" && !inviteForm.password) {
alert({
alertType: "error",
title: t(
"workspace.people.addMember.passwordRequired",
"Password is required",
),
});
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: inviteForm.authType,
forceChange: inviteForm.forceChange,
forceMFA: inviteForm.forceMFA,
});
alert({
alertType: "success",
title: t("workspace.people.addMember.success"),
});
onClose();
onSuccess?.();
// Reset form
setInviteForm({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: "WEB",
forceChange: false,
forceMFA: false,
});
} catch (error: unknown) {
console.error("Failed to invite user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.addMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({
alertType: "error",
title: t(
"workspace.people.emailInvite.emailsRequired",
"Email addresses are required",
),
});
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails, // comma-separated string as required by API
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
// Show success message
alert({
alertType: "success",
title: t("workspace.people.emailInvite.success", {
count: response.successCount,
defaultValue: `Successfully invited ${response.successCount} user(s)`,
}),
});
// Show warning if there were partial failures
if (response.failureCount > 0 && response.errors) {
alert({
alertType: "warning",
title: t(
"workspace.people.emailInvite.partialFailure",
"Some invites failed",
),
body: response.errors,
});
}
onClose();
onSuccess?.();
setEmailInviteForm({
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
} else {
alert({
alertType: "error",
title: t(
"workspace.people.emailInvite.allFailed",
"Failed to invite users",
),
body: response.errors || response.error,
});
}
} catch (error: unknown) {
console.error("Failed to invite users:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.emailInvite.error", "Failed to send invites");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email || undefined,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
frontendBaseUrl: config?.frontendUrl || window.location.origin,
});
actionTakenRef.current = true;
setGeneratedInviteLink(response.inviteUrl);
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.emailSent",
"Invite link generated and sent via email",
),
});
}
} catch (error: unknown) {
console.error("Failed to generate invite link:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.inviteLink.error",
"Failed to generate invite link",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleClose = () => {
if (actionTakenRef.current) {
onSuccess?.();
actionTakenRef.current = false;
}
setGeneratedInviteLink(null);
setInviteMode("direct");
setInviteForm({
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: "WEB",
forceChange: false,
forceMFA: false,
});
setEmailInviteForm({
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
setInviteLinkForm({
email: "",
role: "ROLE_USER",
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
onClose();
};
const handleGoToPlan = () => {
handleClose();
navigate("/settings/adminPlan");
};
const handlePrimaryAction = () => {
if (inviteMode === "email") {
handleEmailInvite();
} else if (inviteMode === "link") {
handleGenerateInviteLink();
} else {
handleInviteUser();
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={handleClose}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.inviteMembers.label", "Invite Members")}
</Text>
{inviteMode === "email" && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t(
"workspace.people.inviteMembers.subtitle",
"Type or paste in emails below, separated by commas. Your workspace will be billed by members.",
)}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper
withBorder
p="sm"
bg={
licenseInfo.availableSlots === 0
? "var(--mantine-color-red-light)"
: "var(--mantine-color-blue-light)"
}
>
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<LocalIcon
icon={licenseInfo.availableSlots > 0 ? "info" : "warning"}
width="1rem"
height="1rem"
/>
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t("workspace.people.license.slotsAvailable", {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`,
})
: t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
</Text>
</Group>
{licenseInfo.availableSlots === 0 && (
<Button size="xs" variant="light" onClick={handleGoToPlan}>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
)}
</Group>
<Text size="xs" c="dimmed">
{t("workspace.people.license.currentUsage", {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`,
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t(
"workspace.people.inviteMode.emailDisabled",
"Email invites require SMTP configuration and mail.enableInvites=true in settings",
)}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as "email" | "direct" | "link");
setGeneratedInviteLink(null);
}}
data={[
{
label: t(
"workspace.people.inviteMode.username",
"Username",
),
value: "direct",
},
{
label: t("workspace.people.inviteMode.link", "Link"),
value: "link",
},
{
label: t("workspace.people.inviteMode.email", "Email"),
value: "email",
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === "link" && (
<>
<TextInput
label={t(
"workspace.people.inviteLink.email",
"Email (optional)",
)}
placeholder={t(
"workspace.people.inviteLink.emailPlaceholder",
"[email protected]",
)}
value={inviteLinkForm.email}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
email: e.currentTarget.value,
})
}
description={t(
"workspace.people.inviteLink.emailDescription",
"If provided, the link will be tied to this email address",
)}
/>
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) =>
setInviteLinkForm({
...inviteLinkForm,
role: value || "ROLE_USER",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) =>
setInviteLinkForm({
...inviteLinkForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<TextInput
label={t(
"workspace.people.inviteLink.expiryHours",
"Link expires in (hours)",
)}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
expiryHours: parseInt(e.currentTarget.value) || 72,
})
}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t(
"workspace.people.inviteLink.sendEmail",
"Send invite link via email",
)}
description={t(
"workspace.people.inviteLink.sendEmailDescription",
"Also send the link to the provided email address",
)}
checked={inviteLinkForm.sendEmail}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
sendEmail: e.currentTarget.checked,
})
}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>
{t(
"workspace.people.inviteLink.generated",
"Invite Link Generated",
)}
</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(
generatedInviteLink,
);
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement("textarea");
textArea.value = generatedInviteLink;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
alert({
alertType: "success",
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
}
}}
>
<LocalIcon
icon="content-copy"
width="1rem"
height="1rem"
/>
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === "email" && config?.enableEmailInvites && (
<>
<Textarea
label={t(
"workspace.people.emailInvite.emails",
"Email Addresses",
)}
placeholder={t(
"workspace.people.emailInvite.emailsPlaceholder",
"[email protected], [email protected]",
)}
value={emailInviteForm.emails}
onChange={(e) =>
setEmailInviteForm({
...emailInviteForm,
emails: e.currentTarget.value,
})
}
minRows={3}
required
/>
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) =>
setEmailInviteForm({
...emailInviteForm,
role: value || "ROLE_USER",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) =>
setEmailInviteForm({
...emailInviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === "direct" && (
<>
<TextInput
label={t("workspace.people.addMember.username")}
placeholder={t(
"workspace.people.addMember.usernamePlaceholder",
)}
value={inviteForm.username}
onChange={(e) =>
setInviteForm({
...inviteForm,
username: e.currentTarget.value,
})
}
required
/>
{/* Auth Type Selector - only show if SSO is enabled */}
{(config?.enableOAuth || config?.enableSaml) && (
<Select
label={t(
"workspace.people.addMember.authType",
"Authentication Type",
)}
data={[
{
value: "WEB",
label: t(
"workspace.people.authType.password",
"Password",
),
},
...(config?.enableOAuth
? [
{
value: "OAUTH2",
label: t(
"workspace.people.authType.oauth",
"OAuth2",
),
},
]
: []),
...(config?.enableSaml
? [
{
value: "SAML2",
label: t("workspace.people.authType.saml", "SAML2"),
},
]
: []),
]}
value={inviteForm.authType}
onChange={(value) =>
setInviteForm({
...inviteForm,
authType: (value as "WEB" | "OAUTH2" | "SAML2") || "WEB",
})
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
description={
inviteForm.authType !== "WEB"
? t(
"workspace.people.authType.ssoDescription",
"User will authenticate via SSO provider",
)
: undefined
}
/>
)}
{/* Password field - only required for WEB auth type */}
{inviteForm.authType === "WEB" && (
<>
<TextInput
label={t("workspace.people.addMember.password")}
type="password"
placeholder={t(
"workspace.people.addMember.passwordPlaceholder",
)}
value={inviteForm.password}
onChange={(e) =>
setInviteForm({
...inviteForm,
password: e.currentTarget.value,
})
}
required
/>
<Checkbox
label={t(
"workspace.people.addMember.forcePasswordChange",
"Force password change on first login",
)}
checked={inviteForm.forceChange}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceChange: e.currentTarget.checked,
})
}
/>
<Checkbox
label={t(
"workspace.people.addMember.forceMFA",
"Force MFA setup on next login",
)}
checked={inviteForm.forceMFA}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceMFA: e.currentTarget.checked,
})
}
/>
</>
)}
<Select
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteForm.role}
onChange={(value) =>
setInviteForm({ ...inviteForm, role: value || "ROLE_USER" })
}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.addMember.team")}
placeholder={t("workspace.people.addMember.teamPlaceholder")}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) =>
setInviteForm({
...inviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
{/* Action Button */}
<Button
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === "email"
? t("workspace.people.emailInvite.submit", "Send Invites")
: inviteMode === "link"
? t("workspace.people.inviteLink.submit", "Generate Link")
: t("workspace.people.addMember.submit")}
</Button>
</Stack>
</Box>
</Modal>
);
}
@@ -0,0 +1,231 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { BASE_PATH } from "@app/constants/app";
type ImageSlide = {
src: string;
alt?: string;
cornerModelUrl?: string;
title?: string;
subtitle?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[];
showBackground?: boolean;
initialSeconds?: number;
slideSeconds?: number;
}) {
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
const mouse = useRef({ x: 0, y: 0 });
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return [];
return imageSlides.map(
(_, i) =>
(i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000,
);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
if (totalSlides <= 1) return;
const timeout = setTimeout(
() => {
setIndex((i) => (i + 1) % totalSlides);
},
durationsMs[index] ?? slideSeconds * 1000,
);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
function TiltImage({
src,
alt,
enabled,
maxDeg = 6,
}: {
src: string;
alt?: string;
enabled: boolean;
maxDeg?: number;
}) {
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
let raf = 0;
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg;
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = "translateY(-2rem)";
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [enabled, maxDeg]);
return (
<img
ref={imgRef}
src={src}
alt={alt ?? "Carousel slide"}
style={{
maxWidth: "86%",
maxHeight: "78%",
objectFit: "contain",
borderRadius: "18px",
background: "transparent",
transform: "translateY(-2rem)",
transition: "transform 80ms ease-out",
willChange: "transform",
transformOrigin: "50% 50%",
}}
/>
);
}
return (
<div
style={{
position: "relative",
overflow: "hidden",
width: "100%",
height: "100%",
}}
>
{showBackground && (
<img
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
alt="Background panel"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{/* Image slides */}
{imageSlides.map((s, idx) => (
<div
key={s.src}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 600ms ease",
opacity: index === idx ? 1 : 0,
perspective: "900px",
}}
>
{(s.title || s.subtitle) && (
<div
style={{
position: "absolute",
bottom: 24 + 32,
left: 0,
right: 0,
textAlign: "center",
padding: "0 2rem",
width: "100%",
}}
>
{s.title && (
<div
style={{
fontSize: 20,
fontWeight: 800,
color: "#ffffff",
textShadow: "0 2px 6px rgba(0,0,0,0.25)",
marginBottom: 6,
}}
>
{s.title}
</div>
)}
{s.subtitle && (
<div
style={{
fontSize: 13,
color: "rgba(255,255,255,0.92)",
textShadow: "0 1px 4px rgba(0,0,0,0.25)",
}}
>
{s.subtitle}
</div>
)}
</div>
)}
<TiltImage
src={s.src}
alt={s.alt}
enabled={index === idx && !!s.followMouseTilt}
maxDeg={s.tiltMaxDeg ?? 6}
/>
</div>
))}
{/* Dot navigation */}
<div
style={{
position: "absolute",
bottom: 16,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
gap: 10,
zIndex: 2,
}}
>
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
style={{
width: "10px",
height: "12px",
borderRadius: "50%",
border: "none",
cursor: "pointer",
backgroundColor:
i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "block",
flexShrink: 0,
}}
/>
))}
</div>
</div>
);
}
export default memo(LoginRightCarousel);
@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import { alert } from "@app/components/toast";
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
// Get current license key for authentication
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo.licenseKey) {
throw new Error(
"No license key found. Please activate a license first.",
);
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
returnUrl,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
setLoading(false);
} catch (error: unknown) {
console.error("Failed to open billing portal:", error);
alert({
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t("billing.manageBilling", "Manage Billing")}
</Button>
);
};
@@ -0,0 +1,41 @@
import React from "react";
import { Button, ButtonProps } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext";
interface UpdateSeatsButtonProps extends Omit<
ButtonProps,
"onClick" | "loading"
> {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
onSuccess,
onError,
...buttonProps
}) => {
const { t } = useTranslation();
const { openUpdateSeats, isLoading } = useUpdateSeats();
const handleClick = async () => {
await openUpdateSeats({
onSuccess,
onError,
});
};
return (
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
);
};
export default UpdateSeatsButton;
@@ -0,0 +1,226 @@
import React, { useState, useEffect } from "react";
import {
Modal,
Button,
Text,
Alert,
Loader,
Stack,
Group,
NumberInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface UpdateSeatsModalProps {
opened: boolean;
onClose: () => void;
currentSeats: number;
minimumSeats: number;
_onSuccess?: () => void;
onError?: (error: string) => void;
onUpdateSeats?: (newSeats: number) => Promise<string>; // Returns billing portal URL
}
type UpdateState = {
status: "idle" | "loading" | "error";
error?: string;
};
const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
opened,
onClose,
currentSeats,
minimumSeats,
_onSuccess,
onError,
onUpdateSeats,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [newSeatCount, setNewSeatCount] = useState<number>(minimumSeats);
// Reset seat count when modal opens
useEffect(() => {
if (opened) {
setNewSeatCount(minimumSeats);
setState({ status: "idle" });
}
}, [opened, minimumSeats]);
const handleUpdateSeats = async () => {
if (!onUpdateSeats) {
setState({
status: "error",
error: "Update function not provided",
});
return;
}
if (newSeatCount < minimumSeats) {
setState({
status: "error",
error: t(
"billing.seatCountTooLow",
"Seat count must be at least {{minimum}} (current number of users)",
{
minimum: minimumSeats,
},
),
});
return;
}
if (newSeatCount === currentSeats) {
setState({
status: "error",
error: t(
"billing.seatCountUnchanged",
"Please select a different seat count",
),
});
return;
}
try {
setState({ status: "loading" });
// Call the update function (will call manage-billing)
const portalUrl = await onUpdateSeats(newSeatCount);
// Redirect to Stripe billing portal
console.log("Redirecting to Stripe billing portal:", portalUrl);
window.location.href = portalUrl;
// Note: No need to call onSuccess here since we're redirecting
// The return flow will handle success notification
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to update seat count";
setState({
status: "error",
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handleClose = () => {
setState({ status: "idle" });
setNewSeatCount(currentSeats);
onClose();
};
const renderContent = () => {
if (state.status === "loading") {
return (
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("billing.preparingUpdate", "Preparing seat update...")}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
{state.status === "error" && (
<Alert color="red" title={t("common.error", "Error")}>
{state.error}
</Alert>
)}
<Stack gap="md" mt="md">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t("billing.currentSeats", "Current Seats")}:
</Text>
<Text size="sm" fw={600}>
{currentSeats}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t("billing.minimumSeats", "Minimum Seats")}:
</Text>
<Text size="sm" c="dimmed">
{minimumSeats} {t("billing.basedOnUsers", "(current users)")}
</Text>
</Group>
</Stack>
<NumberInput
label={t("billing.newSeatCount", "New Seat Count")}
description={t(
"billing.newSeatCountDescription",
"Select the number of seats for your enterprise license",
)}
value={newSeatCount}
onChange={(value) =>
setNewSeatCount(typeof value === "number" ? value : minimumSeats)
}
min={minimumSeats}
max={10000}
step={1}
size="md"
styles={{
input: {
fontSize: "1.5rem",
fontWeight: 500,
textAlign: "center",
},
}}
/>
<Alert
color="blue"
title={t("billing.whatHappensNext", "What happens next?")}
>
<Text size="sm">
{t(
"billing.stripePortalRedirect",
"You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.",
)}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={handleClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleUpdateSeats}
disabled={
newSeatCount === currentSeats || newSeatCount < minimumSeats
}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
</Group>
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t("billing.updateEnterpriseSeats", "Update Enterprise Seats")}
</Text>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default UpdateSeatsModal;
@@ -0,0 +1,372 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import {
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
UPGRADE_BANNER_TEST_EVENT,
type UpgradeBannerTestPayload,
type UpgradeBannerTestScenario,
UPGRADE_BANNER_ALERT_EVENT,
} from "@core/constants/events";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { isOnboardingCompleted } from "@core/components/onboarding/orchestrator/onboardingStorage";
const FRIENDLY_LAST_SEEN_KEY = "upgradeBannerFriendlyLastShownAt";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
// Check if we're on an auth route (evaluated after hooks, used in render)
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
loginEnabled,
totalUsers,
userCountResolved,
userCountLoading,
effectiveIsAdmin: configIsAdmin,
hasPaidLicense,
licenseLoading,
freeTierLimit,
overFreeTierLimit,
weeklyActiveUsers,
scenarioKey,
} = useServerExperience();
const onboardingComplete = isOnboardingCompleted();
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === "undefined") return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
if (!lastShownRaw) return false;
const lastShown = parseInt(lastShownRaw, 10);
if (!Number.isFinite(lastShown)) return false;
return Date.now() - lastShown >= WEEK_IN_MS;
});
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] =
useState<UpgradeBannerTestScenario>(null);
useEffect(() => {
if (!isDev || typeof window === "undefined") {
return;
}
const handleTestEvent = (event: Event) => {
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
setTestScenario(detail?.scenario ?? null);
if (detail?.scenario === "friendly") {
setFriendlyVisible(true);
} else if (!detail?.scenario) {
setFriendlyVisible(false);
}
};
window.addEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
return () => {
window.removeEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
};
}, [isDev]);
const isAdmin = configIsAdmin;
const scenario = isDev ? testScenario : null;
const scenarioIsFriendly = scenario === "friendly";
const scenarioIsUrgentUser = scenario === "urgent-user";
const userCountKnown = typeof totalUsers === "number";
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
const isOverLimit = userCountKnown
? totalUsers > freeTierLimit
: overFreeTierLimit;
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== "unknown" && scenarioKey !== "licensed";
const derivedIsAdmin = scenarioProvidesInfo
? scenarioKey.includes("admin")
: isAdmin;
const derivedHasPaidLicense =
scenarioKey === "licensed"
? true
: scenarioKey === "unknown"
? hasPaidLicense
: false;
const derivedIsUnderLimit = scenarioProvidesInfo
? scenarioKey.includes("under-limit")
: isUnderLimit === true;
const derivedIsOverLimit = scenarioProvidesInfo
? scenarioKey.includes("over-limit")
: isOverLimit === true;
const effectiveIsAdmin = scenario
? scenarioIsUrgentUser
? false
: true
: derivedIsAdmin;
const effectiveTotalUsers =
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
const effectiveTotalUsersLoaded =
scenario != null ? true : baseTotalUsersLoaded;
const effectiveHasPaidLicense =
scenario != null ? false : derivedHasPaidLicense;
const effectiveIsUnderLimit =
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
const effectiveIsOverLimit =
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
const isDerivedAdmin = scenario
? !scenarioIsUrgentUser
: scenarioKey === "login-user-over-limit-no-license"
? false
: effectiveIsAdmin;
const shouldShowFriendlyBase = Boolean(
isDerivedAdmin &&
!effectiveHasPaidLicense &&
effectiveIsUnderLimit &&
effectiveTotalUsersLoaded,
);
const shouldShowUrgentBase = Boolean(
!effectiveHasPaidLicense &&
effectiveTotalUsersLoaded &&
(effectiveIsOverLimit ||
scenarioKey === "login-user-over-limit-no-license"),
);
const shouldEvaluateFriendly = scenario
? scenarioIsFriendly
: Boolean(
shouldShowFriendlyBase &&
!licenseLoading &&
effectiveTotalUsersLoaded &&
onboardingComplete,
);
// Urgent banner should always show when over-limit
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
: Boolean(shouldShowUrgentBase && !licenseLoading);
useEffect(() => {
if (scenario === "friendly") {
return;
}
if (!shouldEvaluateFriendly) {
setFriendlyVisible(false);
return;
}
if (friendlyVisible || typeof window === "undefined" || userCountLoading) {
return;
}
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
const now = Date.now();
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
setFriendlyVisible(due);
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const detail = shouldEvaluateUrgent
? {
active: true,
audience: effectiveIsAdmin ? "admin" : "user",
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
}
: { active: false };
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
);
}, [
shouldEvaluateUrgent,
effectiveIsAdmin,
effectiveTotalUsers,
scenario,
freeTierLimit,
]);
useEffect(() => {
return () => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, {
detail: { active: false },
}),
);
}
};
}, []);
const recordFriendlyLastShown = useCallback(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
}, []);
const handleUpgrade = () => {
recordFriendlyLastShown();
const hideBanner = () => setFriendlyVisible(false);
const navigateFallback = () => {
navigate("/settings/adminPlan");
hideBanner();
};
try {
openCheckout("server", {
minimumSeats: 1,
onSuccess: () => {
hideBanner();
},
onError: () => {
navigateFallback();
},
});
} catch (error) {
console.error(
"[UpgradeBanner] Failed to open checkout, redirecting instead",
error,
);
navigateFallback();
return;
}
// Keep legacy behavior so banner disappears once the user initiates checkout
hideBanner();
};
const handleFriendlyDismiss = () => {
recordFriendlyLastShown();
setFriendlyVisible(false);
};
const handleSeeInfo = () => {
if (typeof window === "undefined" || !effectiveIsAdmin) {
return;
}
const detail: ServerLicenseRequestPayload = {
licenseNotice: {
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
isOverLimit: effectiveIsOverLimit ?? false,
},
selfReportedAdmin: true,
deferUntilTourComplete: false,
};
window.dispatchEvent(
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
);
};
const renderUrgentBanner = () => {
if (!shouldEvaluateUrgent) {
return null;
}
const buttonText = effectiveIsAdmin
? t("upgradeBanner.seeInfo", "See info")
: undefined;
const attentionMessage = effectiveIsAdmin
? t(
"upgradeBanner.attentionBodyAdmin",
"Review the license requirements to keep this server compliant.",
)
: t(
"upgradeBanner.attentionBody",
"Your admin needs to sign in to see more info. Please contact them immediately.",
);
return (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t(
"upgradeBanner.attentionTitle",
"This server needs admin attention",
)}
message={attentionMessage}
buttonText={buttonText}
buttonIcon="info-rounded"
onButtonClick={buttonText ? handleSeeInfo : undefined}
dismissible={false}
minHeight={60}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
);
};
const suppressForNoLogin =
!loginEnabled ||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
// Don't show on auth routes or if neither banner type should show
// Also suppress entirely for no-login servers (treat them as regular users only)
// and, per request, never surface upgrade messaging there when WAU > 5.
if (
onAuthRoute ||
suppressForNoLogin ||
(!friendlyVisible && !shouldEvaluateUrgent)
) {
return null;
}
return (
<>
{friendlyVisible && (
<InfoBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t(
"upgradeBanner.message",
"Get the most out of Stirling PDF with unlimited users and advanced features.",
)}
buttonText={t("upgradeBanner.upgradeButton", "Upgrade Now")}
buttonIcon="upgrade-rounded"
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
show={friendlyVisible}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
borderColor="transparent"
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
buttonVariant="white"
buttonColor="blue"
minHeight={48}
compact
/>
)}
{renderUrgentBanner()}
</>
);
};
export default UpgradeBanner;
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function UpgradeBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<UpgradeBanner />);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
}
@@ -0,0 +1,47 @@
import { Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EnterpriseRequiredBannerProps {
show: boolean;
featureName: string;
}
/**
* Banner that explains enterprise-only features are in demo mode
*/
export default function EnterpriseRequiredBanner({
show,
featureName,
}: EnterpriseRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={
<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />
}
title={t(
"admin.settings.enterpriseRequired.title",
"Enterprise License Required",
)}
color="yellow"
variant="light"
styles={{
root: {
borderLeft: "4px solid var(--mantine-color-yellow-6)",
},
}}
>
<Text size="sm">
{t(
"admin.settings.enterpriseRequired.message",
"An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.",
{ featureName },
)}
</Text>
</Alert>
);
}
@@ -0,0 +1,54 @@
import { Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useNavigate } from "react-router-dom";
export function OverviewHeader() {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate("/login");
} catch (error) {
console.error("Logout error:", error);
}
};
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<div>
<Text fw={600} size="lg">
{t("config.overview.title", "Application Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t(
"config.overview.description",
"Current application settings and configuration details.",
)}
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,517 @@
import React from "react";
import { useTranslation } from "react-i18next";
import {
useConfigNavSections as useCoreConfigNavSections,
createConfigNavSections as createCoreConfigNavSections,
ConfigNavSection,
} from "@core/components/shared/config/configNavSections";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
import TeamsSection from "@app/components/shared/config/configSections/TeamsSection";
import AdminGeneralSection from "@app/components/shared/config/configSections/AdminGeneralSection";
import AdminSecuritySection from "@app/components/shared/config/configSections/AdminSecuritySection";
import AdminConnectionsSection from "@app/components/shared/config/configSections/AdminConnectionsSection";
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
import AdminDatabaseSection from "@app/components/shared/config/configSections/AdminDatabaseSection";
import AdminAdvancedSection from "@app/components/shared/config/configSections/AdminAdvancedSection";
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
/**
* Hook version of proprietary config nav sections with proper i18n support
*/
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
): ConfigNavSection[] => {
const { t } = useTranslation();
// Get the core sections (Preferences + Help)
const sections = useCoreConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
onRequestClose,
);
// Add account management under Preferences
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: "account",
label: t("account.accountSettings", "Account"),
icon: "person-rounded",
component: <AccountSection />,
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
const enableLoginTooltip = t(
"settings.tooltips.enableLoginFirst",
"Enable login mode first",
);
const requiresEnterpriseTooltip = t(
"settings.tooltips.requiresEnterprise",
"Requires Enterprise license",
);
// Workspace
sections.push({
title: t("settings.workspace.title", "Workspace"),
items: [
{
key: "people",
label: t("settings.workspace.people", "People"),
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "teams",
label: t("settings.workspace.teams", "Teams"),
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Configuration
sections.push({
title: t("settings.configuration.title", "Configuration"),
items: [
{
key: "adminGeneral",
label: t("settings.configuration.systemSettings", "System Settings"),
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminFeatures",
label: t("settings.configuration.features", "Features"),
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminStorageSharing",
label: t(
"settings.configuration.storageSharing",
"File Storage & Sharing",
),
icon: "storage-rounded",
component: <AdminStorageSharingSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
{
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminDatabase",
label: t("settings.configuration.database", "Database"),
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAdvanced",
label: t("settings.configuration.advanced", "Advanced"),
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t("settings.securityAuth.title", "Security & Authentication"),
items: [
{
key: "adminSecurity",
label: t("settings.securityAuth.security", "Security"),
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminConnections",
label: t("settings.securityAuth.connections", "Connections"),
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: t("settings.licensingAnalytics.title", "Licensing & Analytics"),
items: [
{
key: "adminPlan",
label: t("settings.licensingAnalytics.plan", "Plan"),
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminAudit",
label: t("settings.licensingAnalytics.audit", "Audit"),
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
{
key: "adminUsage",
label: t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
],
});
// Policies & Privacy
sections.push({
title: t("settings.policiesPrivacy.title", "Policies & Privacy"),
items: [
{
key: "adminLegal",
label: t("settings.policiesPrivacy.legal", "Legal"),
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminPrivacy",
label: t("settings.policiesPrivacy.privacy", "Privacy"),
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
}
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: t("settings.developer.title", "Developer"),
items: [
{
key: "api-keys",
label: t("settings.developer.apiKeys", "API Keys"),
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
// Add Developer section after Preferences (or Workspace if it exists)
const insertIndex = isAdmin ? 2 : 1;
sections.splice(insertIndex, 0, developerSection);
}
return sections;
};
/**
* Deprecated: Use useConfigNavSections hook instead
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn(
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
);
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
// Add account management under Preferences
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
preferencesSection.items.push({
key: "account",
label: "Account",
icon: "person-rounded",
component: <AccountSection />,
});
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
// Workspace
sections.push({
title: "Workspace",
items: [
{
key: "people",
label: "People",
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Configuration
sections.push({
title: "Configuration",
items: [
{
key: "adminGeneral",
label: "System Settings",
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminFeatures",
label: "Features",
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminEndpoints",
label: "Endpoints",
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminDatabase",
label: "Database",
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAdvanced",
label: "Advanced",
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Security & Authentication
sections.push({
title: "Security & Authentication",
items: [
{
key: "adminSecurity",
label: "Security",
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminConnections",
label: "Connections",
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: "Licensing & Analytics",
items: [
{
key: "adminPlan",
label: "Plan",
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAudit",
label: "Audit",
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
{
key: "adminUsage",
label: "Usage Analytics",
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
],
});
// Policies & Privacy
sections.push({
title: "Policies & Privacy",
items: [
{
key: "adminLegal",
label: "Legal",
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminPrivacy",
label: "Privacy",
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
}
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: "Developer",
items: [
{
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
// Add Developer section after Preferences (or Workspace if it exists)
const insertIndex = isAdmin ? 2 : 1;
sections.splice(insertIndex, 0, developerSection);
}
return sections;
};
// Re-export types for convenience
export type {
ConfigNavSection,
ConfigNavItem,
ConfigColors,
} from "@core/components/shared/config/configNavSections";
@@ -0,0 +1,783 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Box,
Group,
Modal,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert as showToast } from "@app/components/toast";
import { useAuth } from "@app/auth/UseSession";
import { accountService } from "@app/services/accountService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { QRCodeSVG } from "qrcode.react";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { BASE_PATH } from "@app/constants/app";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
const AccountSection: React.FC = () => {
const { t } = useTranslation();
const { user, signOut } = useAuth();
const accountLogout = useAccountLogout();
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [currentPasswordForUsername, setCurrentPasswordForUsername] =
useState("");
const [newUsername, setNewUsername] = useState("");
const [usernameError, setUsernameError] = useState("");
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaDisableCode, setMfaDisableCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
const normalizeMfaCode = useCallback(
(value: string) => value.replace(/\D/g, "").slice(0, 6),
[],
);
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const authTypeFromMetadata = useMemo(() => {
const metadata = user?.app_metadata as
| { authType?: string; authenticationType?: string }
| undefined;
return metadata?.authenticationType ?? metadata?.authType;
}, [user?.app_metadata]);
const normalizedAuthType = useMemo(
() =>
(user?.authenticationType ?? authTypeFromMetadata ?? "").toLowerCase(),
[authTypeFromMetadata, user?.authenticationType],
);
const isSsoUser = useMemo(
() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType),
[normalizedAuthType],
);
const userIdentifier = useMemo(
() => user?.email || user?.username || "",
[user?.email, user?.username],
);
const redirectToLogin = useCallback(() => {
window.location.assign("/login");
}, []);
const handleLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSsoUser) {
setPasswordError(
t(
"settings.security.password.ssoDisabled",
"Password changes are managed by your identity provider.",
),
);
return;
}
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
if (newPassword !== confirmPassword) {
setPasswordError(
t("settings.security.password.mismatch", "New passwords do not match."),
);
return;
}
try {
setPasswordSubmitting(true);
setPasswordError("");
await accountService.changePassword(currentPassword, newPassword);
showToast({
alertType: "success",
title: t(
"settings.security.password.success",
"Password updated successfully. Please sign in again.",
),
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setPasswordError(
axiosError.response?.data?.message ||
t(
"settings.security.password.error",
"Unable to update password. Please verify your current password and try again.",
),
);
} finally {
setPasswordSubmitting(false);
}
};
useEffect(() => {
const fetchAccountData = async () => {
setChangeButtonDisabled(true);
try {
const data = await accountService
.getAccountData()
.then((data) => data)
.finally(() => {
setChangeButtonDisabled(false);
});
setMfaEnabled(data.mfaEnabled ?? false);
} catch {
// ignore fetch errors for account data
console.warn("Failed to fetch account data");
} finally {
setChangeButtonDisabled(false);
}
};
void fetchAccountData();
}, []);
const handleStartMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
setMfaSetupModalOpen(true);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.setupFailed",
"Unable to start two-factor setup. Please try again.",
),
);
} finally {
setMfaLoading(false);
}
}, [t]);
const handleEnableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
setMfaLoading(true);
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setMfaEnabled(true);
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode("");
showToast({
alertType: "success",
title: t("account.mfa.enabled", "Two-factor authentication enabled."),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.enableFailed",
"Unable to enable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
}
},
[mfaSetupCode, t],
);
const handleDisableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaDisableCode.trim()) {
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
setMfaLoading(true);
setMfaError("");
await accountService.disableMfa(mfaDisableCode.trim());
setMfaEnabled(false);
setMfaDisableModalOpen(false);
setMfaDisableCode("");
showToast({
alertType: "success",
title: t(
"account.mfa.disabled",
"Two-factor authentication disabled.",
),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t(
"account.mfa.disableFailed",
"Unable to disable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
}
},
[mfaDisableCode, t],
);
const handleCloseMfaSetupModal = useCallback(async () => {
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode("");
setMfaError("");
try {
await accountService.cancelMfaSetup();
} catch {
console.warn("Failed to clear pending MFA setup");
}
}, []);
const handleCloseMfaDisableModal = useCallback(() => {
setMfaDisableModalOpen(false);
setMfaDisableCode("");
setMfaError("");
}, []);
const handleUsernameSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (isSsoUser) {
setUsernameError(
t(
"changeCreds.ssoManaged",
"Your account is managed by your identity provider.",
),
);
return;
}
if (!currentPasswordForUsername || !newUsername) {
setUsernameError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
try {
setUsernameSubmitting(true);
setUsernameError("");
await accountService.changeUsername(
newUsername,
currentPasswordForUsername,
);
showToast({
alertType: "success",
title: t("changeCreds.credsUpdated", "Account updated"),
body: t(
"changeCreds.description",
"Changes saved. Please log in again.",
),
});
setNewUsername("");
setCurrentPasswordForUsername("");
setUsernameModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setUsernameError(
axiosError.response?.data?.message ||
t(
"changeCreds.error",
"Unable to update username. Please verify your password and try again.",
),
);
} finally {
setUsernameSubmitting(false);
}
};
return (
<Stack gap="md">
<div>
<Text fw={600} size="lg">
{t("account.accountSettings", "Account")}
</Text>
<Text size="sm" c="dimmed">
{t("changeCreds.header", "Update Your Account Details")}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{userIdentifier
? t("settings.general.user", "User") + ": " + userIdentifier
: t("account.accountSettings", "Account Settings")}
</Text>
<Stack gap="xs">
{isSsoUser && (
<Alert
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
color="blue"
variant="light"
>
{t(
"changeCreds.ssoManaged",
"Your account is managed by your identity provider.",
)}
</Alert>
)}
<Group gap="sm" wrap="wrap">
{!isSsoUser && (
<Button
leftSection={<LocalIcon icon="key-rounded" />}
onClick={() => setPasswordModalOpen(true)}
>
{t("settings.security.password.update", "Update password")}
</Button>
)}
{!isSsoUser && (
<Button
variant="light"
leftSection={<LocalIcon icon="edit-rounded" />}
onClick={() => setUsernameModalOpen(true)}
>
{t("account.changeUsername", "Change username")}
</Button>
)}
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="logout-rounded" />}
onClick={handleLogout}
>
{t("settings.general.logout", "Log out")}
</Button>
</Group>
</Stack>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={600}>
{t("account.mfa.title", "Two-factor authentication")}
</Text>
<Text size="sm" c="dimmed">
{t(
"account.mfa.description",
"Add an extra layer of security to your account.",
)}
</Text>
{isSsoUser ? (
<Alert
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
color="blue"
variant="light"
>
{t(
"account.mfa.ssoManaged",
"Two-factor authentication for this account is managed by your identity provider.",
)}
</Alert>
) : (
<Group gap="sm" wrap="wrap">
{!mfaEnabled ? (
<Button
leftSection={
<LocalIcon icon="check-circle-outline-rounded" />
}
onClick={handleStartMfaSetup}
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t(
"account.mfa.enableButton",
"Enable two-factor authentication",
)}
</Button>
) : (
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="close-rounded" />}
onClick={() => {
setMfaError("");
setMfaDisableCode("");
setMfaDisableModalOpen(true);
}}
disabled={changeButtonDisabled}
>
{t(
"account.mfa.disableButton",
"Disable two-factor authentication",
)}
</Button>
)}
</Group>
)}
</Stack>
</Paper>
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
title={t("settings.security.title", "Change password")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handlePasswordSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"settings.security.password.subtitle",
"Change your password. You will be logged out after updating.",
)}
</Text>
{passwordError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{passwordError}
</Alert>
)}
<PasswordInput
label={t(
"settings.security.password.current",
"Current password",
)}
placeholder={t(
"settings.security.password.currentPlaceholder",
"Enter your current password",
)}
value={currentPassword}
onChange={(event) =>
setCurrentPassword(event.currentTarget.value)
}
required
/>
<PasswordInput
label={t("settings.security.password.new", "New password")}
placeholder={t(
"settings.security.password.newPlaceholder",
"Enter a new password",
)}
value={newPassword}
onChange={(event) => setNewPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t(
"settings.security.password.confirm",
"Confirm new password",
)}
placeholder={t(
"settings.security.password.confirmPlaceholder",
"Re-enter your new password",
)}
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setPasswordModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
loading={passwordSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("settings.security.password.update", "Update password")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={mfaSetupModalOpen}
onClose={handleCloseMfaSetupModal}
title={t("account.mfa.setupTitle", "Set up two-factor authentication")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleEnableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"account.mfa.setupDescription",
"Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.",
)}
</Text>
{mfaSetupData && (
<Stack gap="sm" align="center">
<Box
style={{
padding: "1.5rem",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG
value={mfaSetupData.otpauthUri || ""}
size={180}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 40,
width: 40,
excavate: true,
}}
/>
</Box>
<Text size="sm" c="dimmed">
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
<strong>{mfaSetupData.secret}</strong>
</Text>
<Text size="xs" c="orange">
{t(
"account.mfa.secretWarning",
"Keep this key private. Anyone with access can generate valid authentication codes.",
)}
</Text>
</Stack>
)}
{mfaError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
<TextInput
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaSetupCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaSetupModal}>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={mfaLoading}>
{t("account.mfa.confirmEnable", "Enable")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={mfaDisableModalOpen}
onClose={handleCloseMfaDisableModal}
title={t(
"account.mfa.disableTitle",
"Disable two-factor authentication",
)}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleDisableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"account.mfa.disableDescription",
"Enter a valid authentication code to disable two-factor authentication.",
)}
</Text>
{mfaError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
<TextInput
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaDisableCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
minLength={6}
autoComplete="one-time-code"
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaDisableModal}>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" color="red" loading={mfaLoading}>
{t("account.mfa.confirmDisable", "Disable")}
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
title={t("account.changeUsername", "Change username")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleUsernameSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"changeCreds.changeUsername",
"Update your username. You will be logged out after updating.",
)}
</Text>
{usernameError && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{usernameError}
</Alert>
)}
<TextInput
label={t("changeCreds.newUsername", "New Username")}
placeholder={t("changeCreds.newUsername", "New Username")}
value={newUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setNewUsername(event.currentTarget.value)
}
required
/>
<PasswordInput
label={t("changeCreds.oldPassword", "Current Password")}
placeholder={t("changeCreds.oldPassword", "Current Password")}
value={currentPasswordForUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCurrentPasswordForUsername(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setUsernameModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
loading={usernameSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("common.save", "Save")}
</Button>
</Group>
</Stack>
</form>
</Modal>
</Stack>
);
};
export default AccountSection;
@@ -0,0 +1,283 @@
import React, { Suspense, lazy, useState, useEffect } from "react";
import { isAxiosError } from "axios";
import {
Tabs,
Loader,
Alert,
Stack,
Text,
Button,
Accordion,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import auditService, {
AuditSystemStatus as AuditStatus,
} from "@app/services/auditService";
import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus";
import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards";
// AuditChartsSection pulls in recharts (~200 kB gzip). It's only rendered when
// the user expands the "Events Over Time" accordion on this page, so we keep
// the rest of the audit page eager and lazy-load just the charts.
const AuditChartsSection = lazy(
() =>
import("@app/components/shared/config/configSections/audit/AuditChartsSection"),
);
import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable";
import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection";
import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
import LocalIcon from "@app/components/shared/LocalIcon";
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">(
"week",
);
useEffect(() => {
const fetchSystemStatus = async () => {
try {
setLoading(true);
setError(null);
const status = await auditService.getSystemStatus();
setSystemStatus(status);
} catch (err: unknown) {
// Check if this is a permission/license error (403/404)
const status = isAxiosError(err) ? err.response?.status : undefined;
if (status === 403 || status === 404) {
setError("enterprise-license-required");
} else {
setError(
err instanceof Error
? err.message
: "Failed to load audit system status",
);
}
} finally {
setLoading(false);
}
};
if (!showDemoData) {
fetchSystemStatus();
} else {
// Provide example audit system status when running in demo mode
setError(null);
setSystemStatus({
enabled: true,
level: "INFO",
retentionDays: 90,
totalEvents: 1234,
pdfMetadataEnabled: true,
captureFileHash: true,
capturePdfAuthor: true,
captureOperationResults: false,
});
setLoading(false);
}
}, [loginEnabled, showDemoData]);
// Override loading state when showing demo data
const actualLoading = showDemoData ? false : loading;
if (actualLoading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
}
if (error) {
if (error === "enterprise-license-required") {
return (
<Alert
color="blue"
title={t("audit.enterpriseRequired", "Enterprise License Required")}
>
{t(
"audit.enterpriseRequiredMessage",
"The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.",
)}
</Alert>
);
}
return (
<Alert
color="red"
title={t("audit.error.title", "Error loading audit system")}
>
{error}
</Alert>
);
}
if (!systemStatus) {
return (
<Alert
color="yellow"
title={t("audit.notAvailable", "Audit system not available")}
>
{t(
"audit.notAvailableMessage",
"The audit system is not configured or not available.",
)}
</Alert>
);
}
const isEnabled = loginEnabled && hasEnterpriseLicense;
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t("settings.licensingAnalytics.audit", "Audit")}
/>
{/* Info banner about audit settings */}
{isEnabled && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t("audit.configureAudit", "Configure Audit Logging")}
color="blue"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
"audit.configureAuditMessage",
"Adjust audit logging level, retention period, and other settings in the Security & Authentication section.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("audit.goToSettings", "Go to Audit Settings")}
</Button>
</Stack>
</Alert>
)}
<AuditSystemStatus status={systemStatus} />
{systemStatus?.enabled ? (
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
{t("audit.tabs.dashboard", "Dashboard")}
</Tabs.Tab>
<Tabs.Tab value="events" disabled={!isEnabled}>
{t("audit.tabs.events", "Audit Events")}
</Tabs.Tab>
<Tabs.Tab value="export" disabled={!isEnabled}>
{t("audit.tabs.export", "Export")}
</Tabs.Tab>
<Tabs.Tab value="clearData" disabled={!isEnabled}>
{t("audit.tabs.clearData", "Clear Data")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="dashboard" pt="md">
<Stack gap="lg">
{/* Stats Cards - Always Visible */}
<AuditStatsCards
loginEnabled={isEnabled}
timePeriod={timePeriod}
/>
{/* Charts in Accordion - Collapsible */}
<Accordion defaultValue={["events-over-time"]} multiple>
<Accordion.Item value="events-over-time">
<Accordion.Control>
{t("audit.charts.overTime", "Events Over Time")}
</Accordion.Control>
<Accordion.Panel>
<Suspense
fallback={
<Center style={{ padding: "2rem" }}>
<Loader />
</Center>
}
>
<AuditChartsSection
loginEnabled={isEnabled}
timePeriod={timePeriod}
onTimePeriodChange={setTimePeriod}
/>
</Suspense>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="events" pt="md">
<AuditEventsTable
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
/>
</Tabs.Panel>
<Tabs.Panel value="export" pt="md">
<AuditExportSection
loginEnabled={isEnabled}
captureFileHash={systemStatus?.captureFileHash}
capturePdfAuthor={systemStatus?.capturePdfAuthor}
captureOperationResults={systemStatus?.captureOperationResults}
/>
</Tabs.Panel>
<Tabs.Panel value="clearData" pt="md">
<AuditClearDataSection loginEnabled={isEnabled} />
</Tabs.Panel>
</Tabs>
) : (
<Alert
color="blue"
title={t("audit.disabled", "Audit logging is disabled")}
>
{t(
"audit.disabledMessage",
"Enable audit logging in your application configuration to track system events.",
)}
</Alert>
)}
</Stack>
);
};
export default AdminAuditSection;
@@ -0,0 +1,995 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
Stack,
Text,
Loader,
Group,
Divider,
Paper,
Switch,
Badge,
Anchor,
Select,
Collapse,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { Z_INDEX_CONFIG_MODAL } from "@app/styles/zIndex";
import ProviderCard from "@app/components/shared/config/configSections/ProviderCard";
import {
Provider,
useAllProviders,
} from "@app/components/shared/config/configSections/providerDefinitions";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeedbackFlags {
noValidDocument?: boolean;
errorProcessing?: boolean;
errorMessage?: boolean;
}
interface FeedbackSettings {
general?: { enabled?: boolean };
channel?: FeedbackFlags;
user?: FeedbackFlags;
}
interface TelegramSettingsData {
enabled?: boolean;
botToken?: string;
botUsername?: string;
pipelineInboxFolder?: string;
customFolderSuffix?: boolean;
enableAllowUserIDs?: boolean;
allowUserIDs?: number[];
enableAllowChannelIDs?: boolean;
allowChannelIDs?: number[];
processingTimeoutSeconds?: number;
pollingIntervalMillis?: number;
feedback?: FeedbackSettings;
}
interface MailSettings {
enabled?: boolean;
enableInvites?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
interface GoogleDriveSettings {
enabled?: boolean;
clientId?: string;
apiKey?: string;
appId?: string;
}
interface OAuth2GenericSettings {
enabled?: boolean;
provider?: string;
issuer?: string;
clientId?: string;
clientSecret?: string;
scopes?: string;
useAsUsername?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
}
interface Saml2Settings {
enabled?: boolean;
provider?: string;
registrationId?: string;
idpMetadataUri?: string;
idpSingleLoginUrl?: string;
idpSingleLogoutUrl?: string;
idpIssuer?: string;
idpCert?: string;
privateKey?: string;
spCert?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
}
interface OAuth2ClientSettings {
clientId?: string;
clientSecret?: string;
scopes?: string;
useAsUsername?: string;
issuer?: string;
}
type ProviderSettings =
| MailSettings
| TelegramSettingsData
| GoogleDriveSettings
| OAuth2GenericSettings
| Saml2Settings
| OAuth2ClientSettings;
interface ConnectionsSettingsData {
oauth2?: {
enabled?: boolean;
issuer?: string;
clientId?: string;
clientSecret?: string;
provider?: string;
autoCreateUser?: boolean;
blockRegistration?: boolean;
useAsUsername?: string;
scopes?: string;
client?: Record<string, OAuth2ClientSettings>;
};
saml2?: Saml2Settings;
mail?: MailSettings;
telegram?: TelegramSettingsData;
ssoAutoLogin?: boolean;
enableMobileScanner?: boolean;
mobileScannerConvertToPdf?: boolean;
mobileScannerImageResolution?: string;
mobileScannerPageFormat?: string;
mobileScannerStretchToFit?: boolean;
googleDriveEnabled?: boolean;
googleDriveClientId?: string;
googleDriveApiKey?: string;
googleDriveAppId?: string;
}
export default function AdminConnectionsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, closeRestartModal, restartServer } =
useRestartServer();
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: "connections",
fetchTransformer: async (): Promise<
ConnectionsSettingsData & { _pending?: Record<string, unknown> }
> => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get(
"/api/v1/admin/settings/section/security",
);
const securityData = securityResponse.data || {};
// Fetch mail settings
const mailResponse = await apiClient.get(
"/api/v1/admin/settings/section/mail",
);
const mailData = mailResponse.data || {};
// Fetch premium settings for SSO Auto Login
const premiumResponse = await apiClient.get(
"/api/v1/admin/settings/section/premium",
);
const premiumData = premiumResponse.data || {};
// Fetch Telegram settings
const telegramResponse = await apiClient.get(
"/api/v1/admin/settings/section/telegram",
);
const telegramData = telegramResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: ConnectionsSettingsData & {
_pending?: Record<string, unknown>;
} = {
oauth2: securityData.oauth2 || {},
saml2: securityData.saml2 || {},
mail: mailData || {},
telegram: telegramData || {},
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false,
mobileScannerConvertToPdf:
systemData.mobileScannerSettings?.convertToPdf !== false,
mobileScannerImageResolution:
systemData.mobileScannerSettings?.imageResolution || "full",
mobileScannerPageFormat:
systemData.mobileScannerSettings?.pageFormat || "A4",
mobileScannerStretchToFit:
systemData.mobileScannerSettings?.stretchToFit || false,
googleDriveEnabled:
premiumData.proFeatures?.googleDrive?.enabled || false,
googleDriveClientId:
premiumData.proFeatures?.googleDrive?.clientId || "",
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || "",
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || "",
};
// Merge pending blocks from all endpoints
const pendingBlock: Record<string, unknown> = {};
if (securityData._pending?.oauth2) {
pendingBlock.oauth2 = securityData._pending.oauth2;
}
if (securityData._pending?.saml2) {
pendingBlock.saml2 = securityData._pending.saml2;
}
if (mailData._pending) {
pendingBlock.mail = mailData._pending;
}
if (telegramData._pending) {
pendingBlock.telegram = telegramData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin =
premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner =
systemData._pending.enableMobileScanner;
}
if (
systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined
) {
pendingBlock.mobileScannerConvertToPdf =
systemData._pending.mobileScannerSettings.convertToPdf;
}
if (
systemData._pending?.mobileScannerSettings?.imageResolution !==
undefined
) {
pendingBlock.mobileScannerImageResolution =
systemData._pending.mobileScannerSettings.imageResolution;
}
if (
systemData._pending?.mobileScannerSettings?.pageFormat !== undefined
) {
pendingBlock.mobileScannerPageFormat =
systemData._pending.mobileScannerSettings.pageFormat;
}
if (
systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined
) {
pendingBlock.mobileScannerStretchToFit =
systemData._pending.mobileScannerSettings.stretchToFit;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.enabled !== undefined
) {
pendingBlock.googleDriveEnabled =
premiumData._pending.proFeatures.googleDrive.enabled;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.clientId !== undefined
) {
pendingBlock.googleDriveClientId =
premiumData._pending.proFeatures.googleDrive.clientId;
}
if (
premiumData._pending?.proFeatures?.googleDrive?.apiKey !== undefined
) {
pendingBlock.googleDriveApiKey =
premiumData._pending.proFeatures.googleDrive.apiKey;
}
if (premiumData._pending?.proFeatures?.googleDrive?.appId !== undefined) {
pendingBlock.googleDriveAppId =
premiumData._pending.proFeatures.googleDrive.appId;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (currentSettings: ConnectionsSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
// Build delta for oauth2 settings
if (currentSettings.oauth2) {
Object.keys(currentSettings.oauth2).forEach((key) => {
if (key !== "client") {
deltaSettings[`security.oauth2.${key}`] = (
currentSettings.oauth2 as Record<string, unknown>
)[key];
}
});
// Build delta for specific OAuth2 providers
const oauth2Client = currentSettings.oauth2.client;
if (oauth2Client) {
Object.keys(oauth2Client).forEach((providerId) => {
const providerSettings = oauth2Client[providerId] as Record<
string,
unknown
>;
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${providerId}.${key}`] =
providerSettings[key];
});
});
}
}
// Build delta for saml2 settings
if (currentSettings.saml2) {
const saml2 = currentSettings.saml2 as Record<string, unknown>;
Object.keys(saml2).forEach((key) => {
deltaSettings[`security.saml2.${key}`] = saml2[key];
});
}
// Mail settings
if (currentSettings.mail) {
const mail = currentSettings.mail as Record<string, unknown>;
Object.keys(mail).forEach((key) => {
deltaSettings[`mail.${key}`] = mail[key];
});
}
// Telegram settings
if (currentSettings.telegram) {
const telegram = currentSettings.telegram as Record<string, unknown>;
Object.keys(telegram).forEach((key) => {
deltaSettings[`telegram.${key}`] = telegram[key];
});
}
// SSO Auto Login
if (currentSettings?.ssoAutoLogin !== undefined) {
deltaSettings["premium.proFeatures.ssoAutoLogin"] =
currentSettings.ssoAutoLogin;
}
// Mobile Scanner settings
if (currentSettings?.enableMobileScanner !== undefined) {
deltaSettings["system.enableMobileScanner"] =
currentSettings.enableMobileScanner;
}
if (currentSettings?.mobileScannerConvertToPdf !== undefined) {
deltaSettings["system.mobileScannerSettings.convertToPdf"] =
currentSettings.mobileScannerConvertToPdf;
}
if (currentSettings?.mobileScannerImageResolution !== undefined) {
deltaSettings["system.mobileScannerSettings.imageResolution"] =
currentSettings.mobileScannerImageResolution;
}
if (currentSettings?.mobileScannerPageFormat !== undefined) {
deltaSettings["system.mobileScannerSettings.pageFormat"] =
currentSettings.mobileScannerPageFormat;
}
if (currentSettings?.mobileScannerStretchToFit !== undefined) {
deltaSettings["system.mobileScannerSettings.stretchToFit"] =
currentSettings.mobileScannerStretchToFit;
}
// Google Drive settings
if (currentSettings?.googleDriveEnabled !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.enabled"] =
currentSettings.googleDriveEnabled;
}
if (currentSettings?.googleDriveClientId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.clientId"] =
currentSettings.googleDriveClientId;
}
if (currentSettings?.googleDriveApiKey !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.apiKey"] =
currentSettings.googleDriveApiKey;
}
if (currentSettings?.googleDriveAppId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.appId"] =
currentSettings.googleDriveAppId;
}
return {
sectionData: {},
deltaSettings,
};
},
});
const { settings, setSettings, loading, fetchSettings, isFieldPending } =
adminSettings;
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
markSaved();
try {
await adminSettings.saveSettings();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === "saml2") {
return settings?.saml2?.enabled === true;
}
if (provider.id === "smtp") {
return settings?.mail?.enabled === true;
}
if (provider.id === "telegram") {
return settings?.telegram?.enabled === true;
}
if (provider.id === "googledrive") {
return settings?.googleDriveEnabled === true;
}
if (provider.id === "oauth2-generic") {
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!providerSettings?.clientId;
};
const getProviderSettings = (provider: Provider): ProviderSettings => {
if (provider.id === "saml2") {
return settings?.saml2 || {};
}
if (provider.id === "smtp") {
return settings?.mail || {};
}
if (provider.id === "telegram") {
return settings?.telegram || {};
}
if (provider.id === "googledrive") {
const gd: GoogleDriveSettings = {
enabled: settings?.googleDriveEnabled,
clientId: settings?.googleDriveClientId,
apiKey: settings?.googleDriveApiKey,
appId: settings?.googleDriveAppId,
};
return gd;
}
if (provider.id === "oauth2-generic") {
const generic: OAuth2GenericSettings = {
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
issuer: settings?.oauth2?.issuer,
clientId: settings?.oauth2?.clientId,
clientSecret: settings?.oauth2?.clientSecret,
scopes: settings?.oauth2?.scopes,
useAsUsername: settings?.oauth2?.useAsUsername,
autoCreateUser: settings?.oauth2?.autoCreateUser,
blockRegistration: settings?.oauth2?.blockRegistration,
};
return generic;
}
// Specific OAuth2 provider settings
return settings?.oauth2?.client?.[provider.id] || {};
};
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
const availableProviders = allProviders.filter(
(p) => !isProviderConfigured(p),
);
const updateProviderSettings = (
provider: Provider,
updatedSettings: Record<string, unknown>,
) => {
if (provider.id === "smtp") {
setSettings({ ...settings, mail: updatedSettings as MailSettings });
} else if (provider.id === "telegram") {
setSettings({
...settings,
telegram: updatedSettings as TelegramSettingsData,
});
} else if (provider.id === "googledrive") {
const gd = updatedSettings as GoogleDriveSettings;
setSettings({
...settings,
googleDriveEnabled: gd.enabled,
googleDriveClientId: gd.clientId,
googleDriveApiKey: gd.apiKey,
googleDriveAppId: gd.appId,
});
} else if (provider.id === "saml2") {
setSettings({ ...settings, saml2: updatedSettings as Saml2Settings });
} else if (provider.id === "oauth2-generic") {
const generic = updatedSettings as OAuth2GenericSettings;
setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } });
} else {
// Specific OAuth2 provider
const clientSettings = updatedSettings as OAuth2ClientSettings;
setSettings({
...settings,
oauth2: {
...settings.oauth2,
client: {
...settings.oauth2?.client,
[provider.id]: clientSettings,
},
},
});
}
};
return (
<div className="settings-section-container">
<Stack gap="xl" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
{t("admin.settings.connections.title", "Connections")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.connections.description",
"Configure external authentication providers like OAuth2 and SAML.",
)}
</Text>
</div>
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.connections.ssoAutoLogin.label",
"SSO Auto Login",
)}
</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
</Group>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.ssoAutoLogin.enable",
"Enable SSO Auto Login",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.ssoAutoLogin.description",
"Automatically redirect to SSO login when authentication is required",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({
...settings,
ssoAutoLogin: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("ssoAutoLogin")} />
</Group>
</div>
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon
icon="qr-code-rounded"
width="1.25rem"
height="1.25rem"
/>
<Text fw={600} size="sm">
{t(
"admin.settings.connections.mobileScanner.label",
"Mobile Phone Upload",
)}
</Text>
</Group>
{/* Documentation Link */}
<Anchor
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
target="_blank"
size="xs"
c="blue"
>
{t(
"admin.settings.connections.documentation",
"View documentation",
)}{" "}
</Anchor>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.connections.mobileScanner.enable",
"Enable QR Code Upload",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.mobileScanner.description",
"Allow users to upload files from mobile devices by scanning a QR code",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t(
"admin.settings.connections.mobileScanner.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.connections.mobileScanner.link",
"Configure in System Settings",
)}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({
...settings,
enableMobileScanner: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableMobileScanner")} />
</Group>
</div>
{/* Mobile Scanner Settings - Only show when enabled */}
<Collapse in={settings?.enableMobileScanner || false}>
<Stack
gap="md"
mt="md"
ml="lg"
style={{
borderLeft: "2px solid var(--mantine-color-gray-3)",
paddingLeft: "1rem",
}}
>
{/* Convert to PDF */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerConvertToPdf",
"Convert Images to PDF",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerConvertToPdfDesc",
"Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerConvertToPdf: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge
show={isFieldPending("mobileScannerConvertToPdf")}
/>
</Group>
</div>
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
{settings?.mobileScannerConvertToPdf !== false && (
<>
{/* Image Resolution */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerImageResolution",
"Image Resolution",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerImageResolutionDesc",
'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.',
)}
</Text>
<Group gap="xs">
<Select
value={
settings?.mobileScannerImageResolution || "full"
}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerImageResolution: value || "full",
});
}}
data={[
{
value: "full",
label: t(
"admin.settings.connections.imageResolutionFull",
"Full (Original Size)",
),
},
{
value: "reduced",
label: t(
"admin.settings.connections.imageResolutionReduced",
"Reduced (Max 1200px)",
),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge
show={isFieldPending("mobileScannerImageResolution")}
/>
</Group>
</div>
{/* Page Format */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerPageFormat",
"Page Format",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerPageFormatDesc",
'PDF page size for converted images. "Keep" uses original image dimensions.',
)}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerPageFormat || "A4"}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerPageFormat: value || "A4",
});
}}
data={[
{
value: "keep",
label: t(
"admin.settings.connections.pageFormatKeep",
"Keep (Original Dimensions)",
),
},
{
value: "A4",
label: t(
"admin.settings.connections.pageFormatA4",
"A4 (210×297mm)",
),
},
{
value: "letter",
label: t(
"admin.settings.connections.pageFormatLetter",
"Letter (8.5×11in)",
),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge
show={isFieldPending("mobileScannerPageFormat")}
/>
</Group>
</div>
{/* Stretch to Fit */}
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.connections.mobileScannerStretchToFit",
"Stretch to Fit",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerStretchToFitDesc",
"Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerStretchToFit || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
mobileScannerStretchToFit: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge
show={isFieldPending("mobileScannerStretchToFit")}
/>
</Group>
</div>
</>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<div>
<Text fw={600} size="md" mb="md">
{t(
"admin.settings.connections.linkedServices",
"Linked Services",
)}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
</Stack>
</div>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t(
"admin.settings.connections.unlinkedServices",
"Unlinked Services",
)}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={adminSettings.saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,435 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Paper,
Text,
Loader,
Group,
MultiSelect,
Switch,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface UISettingsData {
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
}
interface EndpointsSettingsData {
toRemove?: string[];
groupsToRemove?: string[];
}
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: "endpoints",
});
const {
settings: uiSettings,
setSettings: setUiSettings,
loading: uiLoading,
saving: uiSaving,
fetchSettings: fetchUiSettings,
saveSettings: saveUiSettings,
isFieldPending: isUiFieldPending,
} = useAdminSettings<UISettingsData>({
sectionName: "ui",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
fetchUiSettings();
}
}, [loginEnabled, fetchSettings, fetchUiSettings]);
const {
isDirty: isEndpointsDirty,
resetToSnapshot: resetEndpointsSnapshot,
markSaved: markEndpointsSaved,
} = useSettingsDirty(settings, loading);
const {
isDirty: isUiDirty,
resetToSnapshot: resetUiSnapshot,
markSaved: markUiSaved,
} = useSettingsDirty(uiSettings, uiLoading);
const isDirty = isEndpointsDirty || isUiDirty;
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
if (isEndpointsDirty) {
markEndpointsSaved();
await saveSettings();
}
if (isUiDirty) {
markUiSaved();
await saveUiSettings();
}
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
if (isEndpointsDirty) {
const original = resetEndpointsSnapshot();
setSettings(original);
}
if (isUiDirty) {
const original = resetUiSnapshot();
setUiSettings(original);
}
}, [
isEndpointsDirty,
isUiDirty,
resetEndpointsSnapshot,
resetUiSnapshot,
setSettings,
setUiSettings,
]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading || uiLoading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
// Complete list of all endpoints from frontend tool registry (alphabetical)
const commonEndpoints = [
"add-attachments",
"add-image",
"add-page-numbers",
"add-password",
"add-stamp",
"add-watermark",
"adjust-contrast",
"auto-redact",
"auto-rename",
"auto-split-pdf",
"automate",
"booklet-imposition",
"cert-sign",
"compare",
"compress-pdf",
"crop",
"dev-airgapped-docs",
"dev-api-docs",
"dev-folder-scanning-docs",
"dev-sso-guide-docs",
"edit-table-of-contents",
"eml-to-pdf",
"extract-image-scans",
"extract-images",
"file-to-pdf",
"flatten",
"get-info-on-pdf",
"handleData",
"html-to-pdf",
"img-to-pdf",
"markdown-to-pdf",
"merge-pdfs",
"multi-page-layout",
"multi-tool",
"ocr-pdf",
"overlay-pdf",
"pdf-to-csv",
"pdf-to-xlsx",
"pdf-to-epub",
"pdf-to-html",
"pdf-to-img",
"pdf-to-markdown",
"pdf-to-pdfa",
"pdf-to-presentation",
"pdf-to-single-page",
"pdf-to-text",
"pdf-to-word",
"pdf-to-xml",
"pipeline",
"rearrange-pages",
"remove-annotations",
"remove-blanks",
"remove-cert-sign",
"remove-image-pdf",
"remove-pages",
"remove-password",
"repair",
"replace-invert-pdf",
"rotate-pdf",
"sanitize-pdf",
"scale-pages",
"scanner-effect",
"show-javascript",
"sign",
"split-by-size-or-count",
"split-pages",
"split-pdf-by-chapters",
"split-pdf-by-sections",
"text-editor-pdf",
"unlock-pdf-forms",
"update-metadata",
"validate-signature",
"view-pdf",
];
// Complete list of functional and tool groups from EndpointConfiguration.java
const commonGroups = [
// Functional Groups
"PageOps",
"Convert",
"Security",
"Other",
"Advance",
"Automation",
"DeveloperTools",
"DeveloperDocs",
// Tool Groups
"CLI",
"Python",
"OpenCV",
"LibreOffice",
"Unoconvert",
"Java",
"Javascript",
"qpdf",
"Ghostscript",
"ImageMagick",
"tesseract",
"OCRmyPDF",
"Weasyprint",
"Pdftohtml",
"Calibre",
"FFmpeg",
"veraPDF",
"rar",
];
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.endpoints.title", "API Endpoints")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.endpoints.description",
"Control which API endpoints and endpoint groups are available.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.endpoints.management", "Endpoint Management")}
</Text>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.toRemove.label",
"Disabled Endpoints",
)}
</span>
<PendingBadge show={isFieldPending("toRemove")} />
</Group>
}
description={t(
"admin.settings.endpoints.toRemove.description",
"Select individual endpoints to disable",
)}
value={settings.toRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map((endpoint) => ({
value: endpoint,
label: endpoint,
}))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.groupsToRemove.label",
"Disabled Endpoint Groups",
)}
</span>
<PendingBadge show={isFieldPending("groupsToRemove")} />
</Group>
}
description={t(
"admin.settings.endpoints.groupsToRemove.description",
"Select endpoint groups to disable",
)}
value={settings.groupsToRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map((group) => ({
value: group,
label: group,
}))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">
{t(
"admin.settings.endpoints.userDefaults",
"User Preference Defaults",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.endpoints.userDefaultsDescription",
"Set default values for user preferences. Users can override these in their personal settings.",
)}
</Text>
</div>
<Switch
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.defaultHideUnavailableTools.label",
"Hide unavailable tools by default",
)}
</span>
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableTools")}
/>
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableTools.description",
"Remove disabled tools instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableTools || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({
...uiSettings,
defaultHideUnavailableTools: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
<Switch
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.defaultHideUnavailableConversions.label",
"Hide unavailable conversions by default",
)}
</span>
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableConversions")}
/>
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableConversions.description",
"Remove disabled conversion options instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableConversions || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({
...uiSettings,
defaultHideUnavailableConversions: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving || uiSaving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,376 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Badge,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeaturesSettingsData {
serverCertificate?: {
enabled?: boolean;
organizationName?: string;
validity?: number;
regenerateOnStartup?: boolean;
};
}
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<FeaturesSettingsData>({
sectionName: "features",
fetchTransformer: async (): Promise<
FeaturesSettingsData & { _pending?: Record<string, unknown> }
> => {
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: FeaturesSettingsData & {
_pending?: Record<string, unknown>;
} = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: "Stirling-PDF",
validity: 365,
regenerateOnStartup: false,
},
};
// Map pending changes from system._pending.serverCertificate
if (systemData._pending?.serverCertificate) {
result._pending = {
serverCertificate: systemData._pending.serverCertificate,
};
}
return result;
},
saveTransformer: (settings: FeaturesSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
if (settings.serverCertificate) {
deltaSettings["system.serverCertificate.enabled"] =
settings.serverCertificate.enabled;
deltaSettings["system.serverCertificate.organizationName"] =
settings.serverCertificate.organizationName;
deltaSettings["system.serverCertificate.validity"] =
settings.serverCertificate.validity;
deltaSettings["system.serverCertificate.regenerateOnStartup"] =
settings.serverCertificate.regenerateOnStartup;
}
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.features.title", "Features")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.features.description",
"Configure optional features and functionality.",
)}
</Text>
</div>
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.features.serverCertificate.label",
"Server Certificate",
)}
</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.features.serverCertificate.description",
'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality',
)}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.enabled.label",
"Enable Server Certificate",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.enabled.description",
'Enable server-side certificate for "Sign with Stirling-PDF" option',
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
enabled: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge
show={isFieldPending("serverCertificate.enabled")}
/>
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.features.serverCertificate.organizationName.label",
"Organization Name",
)}
</span>
<PendingBadge
show={isFieldPending(
"serverCertificate.organizationName",
)}
/>
</Group>
}
description={t(
"admin.settings.features.serverCertificate.organizationName.description",
"Organization name for generated certificates",
)}
value={
settings.serverCertificate?.organizationName || "Stirling-PDF"
}
onChange={(e) =>
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
organizationName: e.target.value,
},
})
}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.features.serverCertificate.validity.label",
"Certificate Validity (days)",
)}
</span>
<PendingBadge
show={isFieldPending("serverCertificate.validity")}
/>
</Group>
}
description={t(
"admin.settings.features.serverCertificate.validity.description",
"Number of days the certificate will be valid",
)}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) =>
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
validity: Number(value),
},
})
}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
"Regenerate on Startup",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.description",
"Generate new certificate on each application startup",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={
settings.serverCertificate?.regenerateOnStartup ?? false
}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: {
...settings.serverCertificate,
regenerateOnStartup: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge
show={isFieldPending("serverCertificate.regenerateOnStartup")}
/>
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,285 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
} from "@mantine/core";
import WarningIcon from "@mui/icons-material/Warning";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface LegalSettingsData {
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: "legal",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.legal.title", "Legal Documents")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.legal.description",
"Configure links to legal documents and policies.",
)}
</Text>
</div>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t(
"admin.settings.legal.disclaimer.title",
"Legal Responsibility Warning",
)}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
"admin.settings.legal.disclaimer.message",
"By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.",
)}
</Text>
</Alert>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.termsAndConditions.label",
"Terms and Conditions",
)}
</span>
<PendingBadge show={isFieldPending("termsAndConditions")} />
</Group>
}
description={t(
"admin.settings.legal.termsAndConditions.description",
"URL or filename to terms and conditions",
)}
value={settings.termsAndConditions || ""}
onChange={(e) =>
setSettings({
...settings,
termsAndConditions: e.target.value,
})
}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.privacyPolicy.label",
"Privacy Policy",
)}
</span>
<PendingBadge show={isFieldPending("privacyPolicy")} />
</Group>
}
description={t(
"admin.settings.legal.privacyPolicy.description",
"URL or filename to privacy policy",
)}
value={settings.privacyPolicy || ""}
onChange={(e) =>
setSettings({ ...settings, privacyPolicy: e.target.value })
}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.accessibilityStatement.label",
"Accessibility Statement",
)}
</span>
<PendingBadge
show={isFieldPending("accessibilityStatement")}
/>
</Group>
}
description={t(
"admin.settings.legal.accessibilityStatement.description",
"URL or filename to accessibility statement",
)}
value={settings.accessibilityStatement || ""}
onChange={(e) =>
setSettings({
...settings,
accessibilityStatement: e.target.value,
})
}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.legal.cookiePolicy.label",
"Cookie Policy",
)}
</span>
<PendingBadge show={isFieldPending("cookiePolicy")} />
</Group>
}
description={t(
"admin.settings.legal.cookiePolicy.description",
"URL or filename to cookie policy",
)}
value={settings.cookiePolicy || ""}
onChange={(e) =>
setSettings({ ...settings, cookiePolicy: e.target.value })
}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.legal.impressum.label", "Impressum")}
</span>
<PendingBadge show={isFieldPending("impressum")} />
</Group>
}
description={t(
"admin.settings.legal.impressum.description",
"URL or filename to impressum (required in some jurisdictions)",
)}
value={settings.impressum || ""}
onChange={(e) =>
setSettings({ ...settings, impressum: e.target.value })
}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,334 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
TextInput,
NumberInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Anchor,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface MailSettingsData {
enabled?: boolean;
enableInvites?: boolean;
inviteLinkExpiryHours?: number;
host?: string;
port?: number;
username?: string;
password?: string;
from?: string;
}
interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
type MailApiResponse = MailSettingsData &
ApiResponseWithPending<MailSettingsData>;
export default function AdminMailSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: "mail",
fetchTransformer: async (): Promise<
MailSettingsData & { _pending?: Partial<MailSettingsData> }
> => {
const mailResponse = await apiClient.get<MailApiResponse>(
"/api/v1/admin/settings/section/mail",
);
return mailResponse.data || {};
},
saveTransformer: (settings: MailSettingsData) => {
return {
sectionData: settings,
deltaSettings: {},
};
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.mail.title", "Mail Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.mail.description",
"Configure SMTP settings for email notifications.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mail.enabled.label", "Enable Mail")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mail.enabled.description",
"Enable email notifications and SMTP functionality",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.mail.enableInvites.label",
"Enable Email Invites",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mail.enableInvites.description",
"Allow admins to invite users via email with auto-generated passwords",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t(
"admin.settings.mail.frontendUrlNote.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.mail.frontendUrlNote.link",
"Configure in System Settings",
)}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) =>
setSettings({
...settings,
enableInvites: e.target.checked,
})
}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending("enableInvites")} />
</Group>
</Group>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.host.label", "SMTP Host")}
</span>
<PendingBadge show={isFieldPending("host")} />
</Group>
}
description={t(
"admin.settings.mail.host.description",
"SMTP server hostname",
)}
value={settings.host || ""}
onChange={(e) =>
setSettings({ ...settings, host: e.target.value })
}
placeholder="smtp.example.com"
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.port.label", "SMTP Port")}
</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.mail.port.description",
"SMTP server port (typically 587 for TLS, 465 for SSL)",
)}
value={settings.port || 587}
onChange={(value) =>
setSettings({ ...settings, port: Number(value) })
}
min={1}
max={65535}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.username.label", "SMTP Username")}
</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t(
"admin.settings.mail.username.description",
"SMTP authentication username",
)}
value={settings.username || ""}
onChange={(e) =>
setSettings({ ...settings, username: e.target.value })
}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
{t("admin.settings.mail.password.label", "SMTP Password")}
</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t(
"admin.settings.mail.password.description",
"SMTP authentication password",
)}
value={settings.password || ""}
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter SMTP password"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.mail.from.label", "From Address")}
</span>
<PendingBadge show={isFieldPending("from")} />
</Group>
}
description={t(
"admin.settings.mail.from.description",
"Email address to use as sender",
)}
value={settings.from || ""}
onChange={(e) =>
setSettings({ ...settings, from: e.target.value })
}
placeholder="[email protected]"
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,241 @@
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Divider, Loader, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePlans } from "@app/hooks/usePlans";
import licenseService, {
PlanTierGroup,
mapLicenseToTier,
} from "@app/services/licenseService";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { useLicense } from "@app/contexts/LicenseContext";
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { alert } from "@app/components/toast";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
getPreferredCurrency,
setCachedCurrency,
} from "@app/utils/currencyDetection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@core/components/shared/config/LoginRequiredBanner";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { openCheckout } = useCheckout();
const { licenseInfo } = useLicense();
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
// Check if we should use static version
useEffect(() => {
// Only use static version if Supabase is not configured or there's an error
// Stripe key is not required - hosted checkout works without it
if (!isSupabaseConfigured || error) {
setUseStaticVersion(true);
}
}, [error]);
const currencyOptions = [
{ value: "gbp", label: "British pound (GBP, £)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
];
const handleManageClick = useCallback(async () => {
// Block access if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
// Only allow PRO or ENTERPRISE licenses to access billing portal
if (!licenseInfo?.licenseType || licenseInfo.licenseType === "NORMAL") {
throw new Error(
"No valid license found. Please purchase a license before accessing the billing portal.",
);
}
if (!licenseInfo?.licenseKey) {
throw new Error("License key missing. Please contact support.");
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
} catch (error: unknown) {
console.error("Failed to open billing portal:", error);
alert({
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
}
}, [licenseInfo, t, validateLoginEnabled]);
const handleCurrencyChange = useCallback((newCurrency: string) => {
setCurrency(newCurrency);
// Persist user's manual selection to localStorage
setCachedCurrency(newCurrency);
}, []);
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Block access if login is disabled
if (!validateLoginEnabled()) {
return;
}
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === "free") {
return;
}
// Prevent free tier users from directly accessing enterprise (must have server first)
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === "free" && planGroup.tier === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
onSuccess: () => {
// Refetch plans after successful payment
// License context will auto-update
refetch();
},
});
},
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled],
);
const shouldShowLicenseWarning =
licenseAlert.active && licenseAlert.audience === "admin";
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
limit: licenseAlert.freeTierLimit,
});
}
return licenseAlert.totalUsers.toLocaleString();
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById("available-plans-section");
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
// Early returns after all hooks are called
if (loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
}
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t(
"plan.licenseWarning.title",
"Free self-hosted limit reached",
)}
message={t("plan.licenseWarning.body", {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
})}
buttonText={t("plan.licenseWarning.cta", "See plans")}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
onManageClick={handleManageClick}
currency={currency}
onCurrencyChange={handleCurrencyChange}
currencyOptions={currencyOptions}
loginEnabled={loginEnabled}
/>
<Divider />
{/* License Key Section */}
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};
export default AdminPlanSection;
@@ -0,0 +1,235 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
TextInput,
Switch,
Stack,
Paper,
Text,
Loader,
Group,
Alert,
List,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: "premium",
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.premium.title", "Premium & Enterprise")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.premium.description",
"Configure your premium or enterprise license key.",
)}
</Text>
</div>
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t(
"admin.settings.premium.movedFeatures.title",
"Premium Features Distributed",
)}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.movedFeatures.message",
"Premium and Enterprise features are now organized in their respective sections:",
)}
</Text>
<List mt="xs" size="sm">
<List.Item>
<Text size="sm" component="span">
<strong>SSO Auto Login</strong> (PRO) - Connections
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Custom Metadata</strong> (PRO) - General
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Audit Logging</strong> (ENTERPRISE) - Security
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Database Configuration</strong> (ENTERPRISE) - Database
</Text>
</List.Item>
</List>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.premium.license", "License Configuration")}
</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>
{t("admin.settings.premium.key.label", "License Key")}
</span>
<PendingBadge show={isFieldPending("key")} />
</Group>
}
description={t(
"admin.settings.premium.key.description",
"Enter your premium or enterprise license key",
)}
value={settings.key || ""}
onChange={(e) =>
setSettings({ ...settings, key: e.target.value })
}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.premium.enabled.label",
"Enable Premium Features",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.premium.enabled.description",
"Enable license key checks for pro/enterprise features",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,306 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Switch, Stack, Paper, Text, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import apiClient from "@app/services/apiClient";
interface PrivacySettingsData {
enableAnalytics?: boolean;
googleVisibility?: boolean;
metricsEnabled?: boolean;
}
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PrivacySettingsData>({
sectionName: "privacy",
fetchTransformer: async (): Promise<
PrivacySettingsData & { _pending?: Record<string, unknown> }
> => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get("/api/v1/admin/settings/section/metrics"),
apiClient.get("/api/v1/admin/settings/section/system"),
]);
const metrics = metricsResponse.data;
const system = systemResponse.data;
const result: PrivacySettingsData & {
_pending?: Record<string, unknown>;
} = {
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false,
};
// Merge pending blocks from both endpoints
const pendingBlock: Record<string, unknown> = {};
if (system._pending?.enableAnalytics !== undefined) {
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
}
if (system._pending?.googlevisibility !== undefined) {
pendingBlock.googleVisibility = system._pending.googlevisibility;
}
if (metrics._pending?.enabled !== undefined) {
pendingBlock.metricsEnabled = metrics._pending.enabled;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings: PrivacySettingsData) => {
const deltaSettings = {
"system.enableAnalytics": settings.enableAnalytics,
"system.googlevisibility": settings.googleVisibility,
"metrics.enabled": settings.metricsEnabled,
};
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
setSettings(original);
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.privacy.title", "Privacy")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.privacy.description",
"Configure privacy and data collection settings.",
)}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.privacy.analytics", "Analytics & Tracking")}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.enableAnalytics.label",
"Enable Analytics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.enableAnalytics.description",
"Collect anonymous usage analytics to help improve the application",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
enableAnalytics: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableAnalytics")} />
</Group>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.metricsEnabled.label",
"Enable Metrics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.metricsEnabled.description",
"Enable collection of performance and usage metrics",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
metricsEnabled: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("metricsEnabled")} />
</Group>
</div>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t(
"admin.settings.privacy.searchEngine",
"Search Engine Visibility",
)}
</Text>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t(
"admin.settings.privacy.googleVisibility.label",
"Google Visibility",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.googleVisibility.description",
"Allow search engines to index this application",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
googleVisibility: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("googleVisibility")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -0,0 +1,415 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Anchor,
Badge,
Group,
Loader,
Paper,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useNavigate } from "react-router-dom";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
interface StorageSharingSettingsData {
enabled?: boolean;
sharing?: {
enabled?: boolean;
linkEnabled?: boolean;
emailEnabled?: boolean;
};
signing?: {
enabled?: boolean;
};
system?: {
frontendUrl?: string;
};
mail?: {
enabled?: boolean;
};
}
export default function AdminStorageSharingSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<StorageSharingSettingsData>({
sectionName: "storage",
fetchTransformer: async () => {
const [storageResponse, systemResponse, mailResponse] = await Promise.all(
[
apiClient.get("/api/v1/admin/settings/section/storage"),
apiClient.get("/api/v1/admin/settings/section/system"),
apiClient.get("/api/v1/admin/settings/section/mail"),
],
);
const storageData = storageResponse.data || {};
const systemData = systemResponse.data || {};
const mailData = mailResponse.data || {};
return {
...storageData,
system: { frontendUrl: systemData.frontendUrl || "" },
mail: { enabled: mailData.enabled || false },
};
},
saveTransformer: (currentSettings) => ({
sectionData: {
enabled: currentSettings.enabled,
sharing: {
enabled: currentSettings.sharing?.enabled,
linkEnabled: currentSettings.sharing?.linkEnabled,
emailEnabled: currentSettings.sharing?.emailEnabled,
},
signing: {
enabled: currentSettings.signing?.enabled,
},
},
}),
});
useEffect(() => {
if (loginEnabled) {
fetchSettings();
}
}, [loginEnabled]);
const storageEnabled = settings.enabled ?? false;
const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false);
const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());
const mailEnabled = Boolean(settings.mail?.enabled);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
}
try {
markSaved();
await saveSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
if (loginEnabled && loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<div className="settings-section-content">
<Stack gap="sm">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{t("admin.settings.storage.title", "File Storage & Sharing")}
</Text>
<Badge size="sm" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"admin.settings.storage.description",
"Control server storage and sharing options.",
)}
</Text>
</div>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.enabled.label",
"Enable Server File Storage",
)}
</Text>
{isFieldPending("enabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.enabled.description",
"Allow users to store files on the server.",
)}
</Text>
</div>
<Switch
checked={storageEnabled}
onChange={(e) =>
setSettings({ ...settings, enabled: e.currentTarget.checked })
}
disabled={!loginEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.enabled.label",
"Enable Sharing",
)}
</Text>
{isFieldPending("sharing.enabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.enabled.description",
"Allow users to share stored files.",
)}
</Text>
</div>
<Switch
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.links.label",
"Enable Share Links",
)}
</Text>
{isFieldPending("sharing.linkEnabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.links.description",
"Allow sharing via signed-in links.",
)}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
{t(
"admin.settings.storage.sharing.links.frontendUrlNote",
"Requires a Frontend URL. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.storage.sharing.links.frontendUrlLink",
"Configure in System Settings",
)}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
linkEnabled: e.currentTarget.checked,
},
})
}
disabled={
!loginEnabled || !sharingEnabled || !frontendUrlConfigured
}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.sharing.email.label",
"Enable Email Sharing",
)}
</Text>
{isFieldPending("sharing.emailEnabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.sharing.email.description",
"Allow sharing with email addresses.",
)}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
{t(
"admin.settings.storage.sharing.email.mailNote",
"Requires mail configuration. ",
)}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminConnections");
}}
c="orange"
td="underline"
>
{t(
"admin.settings.storage.sharing.email.mailLink",
"Configure Mail Settings",
)}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: {
...settings.sharing,
emailEnabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t(
"admin.settings.storage.signing.enabled.label",
"Enable Group Signing",
)}
</Text>
{isFieldPending("signing.enabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.signing.enabled.description",
"Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.",
)}
</Text>
</div>
<Switch
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
signing: {
...settings.signing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Stack,
Group,
Text,
Button,
SegmentedControl,
Loader,
Alert,
Card,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import usageAnalyticsService, {
EndpointStatisticsResponse,
} from "@app/services/usageAnalyticsService";
import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart";
import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [displayMode, setDisplayMode] = useState<"top10" | "top20" | "all">(
"top10",
);
const [dataType, setDataType] = useState<"all" | "api" | "ui">("api");
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
const totalVisits = 15847;
const allEndpoints = [
{
endpoint: "merge-pdfs",
visits: 3245,
percentage: (3245 / totalVisits) * 100,
},
{
endpoint: "compress-pdf",
visits: 2891,
percentage: (2891 / totalVisits) * 100,
},
{
endpoint: "pdf-to-img",
visits: 2156,
percentage: (2156 / totalVisits) * 100,
},
{
endpoint: "split-pdf",
visits: 1834,
percentage: (1834 / totalVisits) * 100,
},
{
endpoint: "rotate-pdf",
visits: 1523,
percentage: (1523 / totalVisits) * 100,
},
{
endpoint: "ocr-pdf",
visits: 1287,
percentage: (1287 / totalVisits) * 100,
},
{
endpoint: "add-watermark",
visits: 945,
percentage: (945 / totalVisits) * 100,
},
{
endpoint: "extract-images",
visits: 782,
percentage: (782 / totalVisits) * 100,
},
{
endpoint: "add-password",
visits: 621,
percentage: (621 / totalVisits) * 100,
},
{
endpoint: "html-to-pdf",
visits: 563,
percentage: (563 / totalVisits) * 100,
},
{
endpoint: "remove-password",
visits: 487,
percentage: (487 / totalVisits) * 100,
},
{
endpoint: "pdf-to-pdfa",
visits: 423,
percentage: (423 / totalVisits) * 100,
},
{
endpoint: "extract-pdf-metadata",
visits: 356,
percentage: (356 / totalVisits) * 100,
},
{
endpoint: "add-page-numbers",
visits: 298,
percentage: (298 / totalVisits) * 100,
},
{ endpoint: "crop", visits: 245, percentage: (245 / totalVisits) * 100 },
{
endpoint: "flatten",
visits: 187,
percentage: (187 / totalVisits) * 100,
},
{
endpoint: "sanitize-pdf",
visits: 134,
percentage: (134 / totalVisits) * 100,
},
{
endpoint: "auto-split-pdf",
visits: 98,
percentage: (98 / totalVisits) * 100,
},
{
endpoint: "scale-pages",
visits: 76,
percentage: (76 / totalVisits) * 100,
},
{
endpoint: "compare-pdfs",
visits: 42,
percentage: (42 / totalVisits) * 100,
},
];
let filteredEndpoints = allEndpoints;
if (displayMode === "top10") {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === "top20") {
filteredEndpoints = allEndpoints.slice(0, 20);
}
return {
totalVisits,
totalEndpoints: filteredEndpoints.length,
endpoints: filteredEndpoints,
};
}, [displayMode]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const limit =
displayMode === "all" ? undefined : displayMode === "top10" ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(
limit,
dataType,
);
setData(response);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load usage statistics",
);
} finally {
setLoading(false);
}
}, [dataType, displayMode]);
useEffect(() => {
if (!showDemoData) {
fetchData();
return;
}
// Provide example usage analytics data when running in demo mode
setError(null);
setData(buildDemoUsageData());
setLoading(false);
}, [buildDemoUsageData, fetchData, showDemoData]);
const handleRefresh = () => {
if (showDemoData) {
setData(buildDemoUsageData());
return;
}
fetchData();
};
const getDisplayModeLabel = () => {
switch (displayMode) {
case "top10":
return t("usage.showing.top10", "Top 10");
case "top20":
return t("usage.showing.top20", "Top 20");
case "all":
return t("usage.showing.all", "All");
default:
return "";
}
};
// Override loading state when showing demo data
const actualLoading = showDemoData ? false : loading;
// Early returns for loading/error states
if (actualLoading) {
return (
<div
style={{ display: "flex", justifyContent: "center", padding: "2rem" }}
>
<Loader size="lg" />
</div>
);
}
if (error) {
return (
<Alert
color="red"
title={t("usage.error", "Error loading usage statistics")}
>
{error}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title={t("usage.noData", "No data available")}>
{t(
"usage.noDataMessage",
"No usage statistics are currently available.",
)}
</Alert>
);
}
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
endpoint:
endpoint.endpoint ?? t("usage.table.unknownEndpoint", "Unknown endpoint"),
visits: Number.isFinite(endpoint.visits) ? Math.max(0, endpoint.visits) : 0,
percentage: Number.isFinite(endpoint.percentage)
? Math.max(0, endpoint.percentage)
: 0,
}));
const chartData = endpoints.map((e) => ({
label: e.endpoint,
value: Number.isFinite(e.visits) ? Math.max(0, e.visits) : 0,
}));
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
const totalVisits = Number.isFinite(data?.totalVisits)
? Math.max(0, data?.totalVisits ?? 0)
: displayedVisits;
const totalEndpoints = Number.isFinite(data?.totalEndpoints)
? Math.max(0, data?.totalEndpoints ?? 0)
: endpoints.length;
const displayedPercentage =
totalVisits > 0
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: "0";
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
)}
/>
{/* Info banner about usage analytics and audit relationship */}
{loginEnabled && hasEnterpriseLicense && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t("usage.aboutUsageAnalytics", "About Usage Analytics")}
color="cyan"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
"usage.usageAnalyticsExplanation",
"Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.",
)}
</Text>
<Group gap="sm">
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.configureSettings", "Configure Analytics Settings")}
</Button>
<Button
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.viewAuditLogs", "View Audit Logs")}
</Button>
</Group>
</Stack>
</Alert>
)}
{/* Controls */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group>
<SegmentedControl
value={displayMode}
onChange={(value) =>
setDisplayMode(value as "top10" | "top20" | "all")
}
disabled={showDemoData}
data={[
{
value: "top10",
label: t("usage.controls.top10", "Top 10"),
},
{
value: "top20",
label: t("usage.controls.top20", "Top 20"),
},
{
value: "all",
label: t("usage.controls.all", "All"),
},
]}
/>
<Button
variant="outline"
leftSection={
<LocalIcon icon="refresh" width="1rem" height="1rem" />
}
onClick={handleRefresh}
loading={loading}
disabled={showDemoData}
>
{t("usage.controls.refresh", "Refresh")}
</Button>
</Group>
</Group>
<Group>
<Text size="sm" fw={500}>
{t("usage.controls.dataTypeLabel", "Data Type:")}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as "all" | "api" | "ui")}
disabled={showDemoData}
data={[
{
value: "all",
label: t("usage.controls.dataType.all", "All"),
},
{
value: "api",
label: t("usage.controls.dataType.api", "API"),
},
{
value: "ui",
label: t("usage.controls.dataType.ui", "UI"),
},
]}
/>
</Group>
{/* Statistics Summary */}
<Group gap="xl" style={{ flexWrap: "wrap" }}>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.totalEndpoints", "Total Endpoints")}
</Text>
<Text size="lg" fw={600}>
{totalEndpoints}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.totalVisits", "Total Visits")}
</Text>
<Text size="lg" fw={600}>
{totalVisits.toLocaleString()}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.showing", "Showing")}
</Text>
<Text size="lg" fw={600}>
{getDisplayModeLabel()}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("usage.stats.selectedVisits", "Selected Visits")}
</Text>
<Text size="lg" fw={600}>
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
</Text>
</div>
</Group>
</Stack>
</Card>
{/* Chart and Table */}
<UsageAnalyticsChart data={chartData} />
<UsageAnalyticsTable data={endpoints} />
</Stack>
);
};
export default AdminUsageSection;
@@ -0,0 +1,197 @@
import React, { useState } from "react";
import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core";
// eslint-disable-next-line no-restricted-imports
import ApiKeySection from "./apiKeys/ApiKeySection";
// eslint-disable-next-line no-restricted-imports
import RefreshModal from "./apiKeys/RefreshModal";
// eslint-disable-next-line no-restricted-imports
import useApiKey from "./apiKeys/hooks/useApiKey";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
export default function ApiKeys() {
const [copied, setCopied] = useState<string | null>(null);
const [showRefreshModal, setShowRefreshModal] = useState(false);
const { t } = useTranslation();
const {
apiKey,
isLoading: apiKeyLoading,
refresh,
isRefreshing,
error: apiKeyError,
refetch,
} = useApiKey();
const copy = async (text: string, tag: string) => {
try {
// Try modern Clipboard API first (requires HTTPS)
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
} else {
// Fallback for HTTP: use old execCommand method
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
if (document.execCommand("copy")) {
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
}
document.body.removeChild(textarea);
}
} catch (e) {
console.error("Failed to copy:", e);
}
};
const refreshKeys = async () => {
try {
await refresh();
} finally {
setShowRefreshModal(false);
}
};
return (
<Stack gap={20} p={0}>
<Text size="sm" c="dimmed">
{t(
"config.apiKeys.intro",
"Use your API key to programmatically access Stirling PDF's processing capabilities.",
)}
</Text>
<Paper
p="md"
radius="md"
style={{
background: "var(--bg-muted)",
border: "1px solid var(--border-subtle)",
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<LocalIcon
icon="info-rounded"
width={18}
height={18}
style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }}
/>
<Stack gap={8} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("config.apiKeys.docsTitle", "API Documentation")}
</Text>
<Text size="sm" c="dimmed">
{t(
"config.apiKeys.docsDescription",
"Learn more about integrating with Stirling PDF:",
)}
</Text>
<Stack gap={4}>
<Text size="sm">
<Anchor
href="https://docs.stirlingpdf.com/API"
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.docsLink", "API Documentation")}
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
<Text size="sm">
<Anchor
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.schemaLink", "API Schema Reference")}
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
</Stack>
</Stack>
</Group>
</Paper>
{apiKeyError && (
<Text size="sm" c="red.5">
{t(
"config.apiKeys.generateError",
"We couldn't generate your API key.",
)}{" "}
<Anchor
component="button"
underline="always"
onClick={refetch}
c="red.4"
>
{t("common.retry", "Retry")}
</Anchor>
</Text>
)}
{apiKeyLoading ? (
<div
style={{
padding: 18,
borderRadius: 12,
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="center" gap={12} wrap="nowrap">
<Skeleton height={36} style={{ flex: 1 }} />
<Skeleton height={32} width={76} />
<Skeleton height={32} width={92} />
</Group>
</div>
) : (
<ApiKeySection
publicKey={apiKey ?? ""}
copied={copied}
onCopy={copy}
onRefresh={() => setShowRefreshModal(true)}
disabled={isRefreshing}
/>
)}
<Text size="sm" c="dimmed" style={{ marginTop: -8 }}>
{t(
"config.apiKeys.usage",
"Include this key in the X-API-KEY header with all API requests.",
)}
</Text>
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}
@@ -0,0 +1,838 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Button,
Table,
ActionIcon,
Badge,
Loader,
Group,
Modal,
Select,
CloseButton,
Tooltip,
Menu,
Avatar,
Box,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import {
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
interface TeamDetailsSectionProps {
teamId: number;
onBack: () => void;
}
export default function TeamDetailsSection({
teamId,
onBack,
}: TeamDetailsSectionProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState<Team | null>(null);
const [teamUsers, setTeamUsers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [userLastRequest, setUserLastRequest] = useState<
Record<string, number>
>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] =
useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
availableSlots: number;
} | null>(null);
const [mailEnabled, setMailEnabled] = useState(false);
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
useEffect(() => {
fetchTeamDetails();
fetchAllTeams();
}, [teamId]);
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([
teamService.getTeamDetails(teamId),
userManagementService.getUsers(),
]);
console.log("[TeamDetailsSection] Raw data:", data);
setTeam(data.team);
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
setAvailableUsers(
Array.isArray(data.availableUsers) ? data.availableUsers : [],
);
setUserLastRequest(data.userLastRequest || {});
// Store license information
setLicenseInfo({
availableSlots: adminData.availableSlots,
});
setMailEnabled(adminData.mailEnabled);
setLockedUsers(adminData.lockedUsers || []);
} catch (error) {
console.error("Failed to fetch team details:", error);
alert({
alertType: "error",
title: t("workspace.teams.loadError", "Failed to load team details"),
});
onBack();
} finally {
setLoading(false);
}
};
const fetchAllTeams = async () => {
try {
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error("Failed to fetch teams:", error);
}
};
const handleAddMember = async () => {
if (!selectedUserId) {
alert({
alertType: "error",
title: t(
"workspace.teams.addMemberToTeam.selectUserRequired",
"Please select a user",
),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({
alertType: "success",
title: t(
"workspace.teams.addMemberToTeam.success",
"User added to team successfully",
),
});
setAddMemberModalOpened(false);
setSelectedUserId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to add member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.addMemberToTeam.error",
"Failed to add user to team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRemoveMember = async (user: User) => {
if (
!window.confirm(
t(
"workspace.teams.confirmRemove",
`Remove ${user.username} from this team?`,
),
)
) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find((t) => t.name === "Default");
if (!defaultTeam) {
throw new Error("Default team not found");
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
defaultTeam.id,
);
alert({
alertType: "success",
title: t(
"workspace.teams.removeMemberSuccess",
"User removed from team",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to remove member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.removeMemberError",
"Failed to remove user from team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.unlockUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("[TeamDetailsSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
};
const openChangeTeamModal = (user: User) => {
setSelectedUser(user);
setSelectedTeamId(user.team?.id?.toString() || "");
setChangeTeamModalOpened(true);
};
const openChangePasswordModal = (user: User) => {
setPasswordUser(user);
setChangePasswordModalOpened(true);
};
const closeChangePasswordModal = () => {
setChangePasswordModalOpened(false);
setPasswordUser(null);
};
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({
alertType: "error",
title: t(
"workspace.teams.changeTeam.selectTeamRequired",
"Please select a team",
),
});
return;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(
selectedUser.username,
selectedUser.rolesAsString || "ROLE_USER",
parseInt(selectedTeamId),
);
alert({
alertType: "success",
title: t(
"workspace.teams.changeTeam.success",
"Team changed successfully",
),
});
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to change team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.changeTeam.error", "Failed to change team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
if (loading) {
return (
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t("workspace.teams.loadingDetails", "Loading team details...")}
</Text>
</Stack>
);
}
if (!team) {
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t("workspace.teams.teamNotFound", "Team not found")}
</Text>
<Button variant="light" onClick={onBack}>
{t("workspace.teams.backToTeams", "Back to Teams")}
</Button>
</Stack>
);
}
return (
<Stack gap="lg">
{/* Header with back button */}
<Group>
<ActionIcon variant="subtle" onClick={onBack}>
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text fw={600} size="lg">
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t("workspace.teams.memberCount", { count: teamUsers.length })}{" "}
{teamUsers.length === 1 ? "member" : "members"}
</Text>
</div>
</Group>
{/* Add Member Button */}
<Group justify="flex-end">
<Tooltip
label={t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Button
leftSection={
<LocalIcon icon="person-add" width="1rem" height="1rem" />
}
onClick={() => setAddMemberModalOpened(true)}
disabled={
team.name === "Internal" ||
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
}
>
{t("workspace.teams.addMember")}
</Button>
</Tooltip>
</Group>
{/* Members Table */}
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
>
{t("workspace.people.user")}
</Table.Th>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
w={100}
>
{t("workspace.people.role")}
</Table.Th>
<Table.Th w={50}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noMembers", "No members in this team")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teamUsers.map((user) => {
const isActive =
userLastRequest[user.username] &&
Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
return (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t("workspace.people.disabled", "Disabled")
: isActive
? t(
"workspace.people.activeSession",
"Active session",
)
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: isActive
? "2px solid var(--mantine-color-green-6)"
: "none",
opacity: user.enabled ? 1 : 0.5,
},
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Tooltip
label={user.username}
disabled={user.username.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
maw={200}
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
</Text>
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t("workspace.people.lockedBadge", "Locked")}
</Badge>
)}
</Group>
{user.email && (
<Text
size="xs"
c="dimmed"
truncate
style={{ lineHeight: 1.3 }}
>
{user.email}
</Text>
)}
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
color={
(user.rolesAsString || "").includes("ROLE_ADMIN")
? "blue"
: "cyan"
}
variant="light"
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin")
: t("workspace.people.member")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication:{" "}
{user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{userLastRequest[user.username]
? new Date(
userLastRequest[user.username],
).toLocaleString()
: "Never"}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" color="gray" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={
<LocalIcon
icon="swap-horiz"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === "Internal"}
>
{t(
"workspace.teams.changeTeam.label",
"Change Team",
)}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon
icon="lock"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangePasswordModal(user)}
disabled={processing}
>
{t(
"workspace.people.changePassword.action",
"Change password",
)}
</Menu.Item>
{isLockedUser(user) && (
<Menu.Item
leftSection={
<LocalIcon
icon="lock-open"
width="1rem"
height="1rem"
/>
}
onClick={() => handleUnlockUser(user)}
disabled={processing}
>
{t(
"workspace.people.unlockAccount",
"Unlock Account",
)}
</Menu.Item>
)}
{team.name !== "Internal" &&
team.name !== "Default" && (
<Menu.Item
leftSection={
<LocalIcon
icon="person-remove"
width="1rem"
height="1rem"
/>
}
onClick={() => handleRemoveMember(user)}
disabled={processing}
>
{t(
"workspace.teams.removeMember",
"Remove from team",
)}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={
<LocalIcon
icon="delete"
width="1rem"
height="1rem"
/>
}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === "Internal"}
>
{t("workspace.people.deleteUser", "Delete User")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchTeamDetails}
mailEnabled={mailEnabled}
/>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Change Team Modal */}
<Modal
opened={changeTeamModalOpened}
onClose={() => setChangeTeamModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="swap-horiz"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.changeTeam.title", "Change Team")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.changeTeam.changing", "Moving")}{" "}
<strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
placeholder={t(
"workspace.teams.changeTeam.selectTeamPlaceholder",
"Choose a team",
)}
data={allTeams
.filter((t) => t.name !== "Internal")
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Button
onClick={handleChangeTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.changeTeam.submit", "Change Team")}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,642 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Button,
TextInput,
Table,
ActionIcon,
Menu,
Badge,
Loader,
Group,
Modal,
Select,
CloseButton,
Tooltip,
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState("");
const [renameTeamName, setRenameTeamName] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
useEffect(() => {
fetchTeams();
}, []);
const fetchTeams = async () => {
try {
setLoading(true);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
{ id: 3, name: "Internal", userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error("Failed to fetch teams:", error);
alert({ alertType: "error", title: "Failed to load teams" });
} finally {
setLoading(false);
}
};
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({
alertType: "error",
title: t("workspace.teams.createTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({
alertType: "success",
title: t("workspace.teams.createTeam.success"),
});
setNewTeamName("");
setCreateModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to create team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.createTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({
alertType: "error",
title: t("workspace.teams.renameTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({
alertType: "success",
title: t("workspace.teams.renameTeam.success"),
});
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to rename team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.renameTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotDeleteInternal"),
});
return;
}
if (!confirm(t("workspace.teams.confirmDelete"))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({
alertType: "success",
title: t("workspace.teams.deleteTeam.success"),
});
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to delete team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.deleteTeam.error");
alert({ alertType: "error", title: errorMessage });
}
};
const openRenameModal = (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotRenameInternal"),
});
return;
}
setSelectedTeam(team);
setRenameTeamName(team.name);
setRenameModalOpened(true);
};
const openAddMemberModal = async (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
title: t("workspace.teams.cannotAddToInternal"),
});
return;
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error("Failed to fetch users:", error);
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.userRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(
selectedTeam.id,
parseInt(selectedUserId),
);
alert({
alertType: "success",
title: t("workspace.teams.addMemberToTeam.success"),
});
setSelectedUserId("");
setSelectedTeam(null);
setAddMemberModalOpened(false);
await fetchTeams();
} catch (error) {
console.error("Failed to add member to team:", error);
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
} finally {
setProcessing(false);
}
};
// If viewing team details, render TeamDetailsSection
if (viewingTeamId !== null) {
return (
<TeamDetailsSection
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
}
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t("workspace.teams.loading", "Loading teams...")}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("workspace.teams.title")}
</Text>
<Text size="sm" c="dimmed">
{t("workspace.teams.description")}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={() => setCreateModalOpened(true)}
disabled={!loginEnabled}
>
{t("workspace.teams.createNewTeam")}
</Button>
</Group>
{/* Teams Table */}
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("workspace.teams.teamName")}
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
fontSize: "0.875rem",
color: "var(--mantine-color-gray-7)",
}}
>
{t("workspace.teams.totalMembers")}
</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noTeamsFound")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: loginEnabled ? "pointer" : "default" }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Tooltip
label={team.name}
disabled={team.name.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
maw={200}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{team.name}
</Text>
</Tooltip>
{team.name === "Internal" && (
<Badge size="xs" color="gray" variant="light">
{t("workspace.teams.system")}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{team.userCount || 0}
</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
disabled={!loginEnabled}
>
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={
<LocalIcon
icon="visibility"
width="1rem"
height="1rem"
/>
}
onClick={() => setViewingTeamId(team.id)}
disabled={!loginEnabled}
>
{t("workspace.teams.viewTeam", "View Team")}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon icon="group" width="1rem" height="1rem" />
}
onClick={() => openAddMemberModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.addMember")}
</Menu.Item>
<Menu.Item
leftSection={
<LocalIcon icon="edit" width="1rem" height="1rem" />
}
onClick={() => openRenameModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.renameTeamLabel")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={
<LocalIcon icon="delete" width="1rem" height="1rem" />
}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === "Internal"}
>
{t("workspace.teams.deleteTeamLabel")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Create Team Modal */}
<Modal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="group-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.createTeam.title")}
</Text>
</Stack>
<TextInput
label={t("workspace.teams.createTeam.teamName")}
placeholder={t("workspace.teams.createTeam.teamNamePlaceholder")}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button
onClick={handleCreateTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.createTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Rename Team Modal */}
<Modal
opened={renameModalOpened}
onClose={() => setRenameModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="edit"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.renameTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.renameTeam.renaming")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t("workspace.teams.renameTeam.newTeamName")}
placeholder={t(
"workspace.teams.renameTeam.newTeamNamePlaceholder",
)}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button
onClick={handleRenameTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.renameTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
{/* Add Member Modal */}
<Modal
opened={addMemberModalOpened}
onClose={() => setAddMemberModalOpened(false)}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t(
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
)}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
{selectedUserId &&
availableUsers.find((u) => u.id.toString() === selectedUserId)
?.team && (
<Text size="xs" c="orange">
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
</Modal>
</Stack>
);
}
@@ -0,0 +1,102 @@
import React from "react";
import { Box, Button, Group, Paper } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import FitText from "@app/components/shared/FitText";
import { useTranslation } from "react-i18next";
interface ApiKeySectionProps {
publicKey: string;
copied: string | null;
onCopy: (text: string, tag: string) => void;
onRefresh: () => void;
disabled?: boolean;
}
export default function ApiKeySection({
publicKey,
copied,
onCopy,
onRefresh,
disabled,
}: ApiKeySectionProps) {
const { t } = useTranslation();
return (
<>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="flex-end" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Box
style={{
background: "var(--api-keys-input-bg)",
border: "1px solid var(--api-keys-input-border)",
borderRadius: 8,
padding: "8px 12px",
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14,
minHeight: 36,
display: "flex",
alignItems: "center",
}}
aria-label={t(
"config.apiKeys.publicKeyAriaLabel",
"Public API key",
)}
>
<FitText text={publicKey} />
</Box>
</Box>
<Button
size="sm"
variant="light"
onClick={() => onCopy(publicKey, "public")}
leftSection={
<LocalIcon icon="content-copy-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 12,
},
}}
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
>
{copied === "public"
? t("common.copied", "Copied!")
: t("common.copy", "Copy")}
</Button>
<Button
size="sm"
variant="light"
onClick={onRefresh}
leftSection={
<LocalIcon icon="refresh-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 8,
},
}}
disabled={disabled}
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
>
{t("common.refresh", "Refresh")}
</Button>
</Group>
</Paper>
</>
);
}
@@ -0,0 +1,57 @@
import React from "react";
import { Modal, Stack, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface RefreshModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => void;
}
export default function RefreshModal({
opened,
onClose,
onConfirm,
}: RefreshModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onClose}
title={t("config.apiKeys.refreshModal.title", "Refresh API Keys")}
centered
size="sm"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Text size="sm" c="red">
{t(
"config.apiKeys.refreshModal.warning",
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
)}
</Text>
<Text size="sm">
{t(
"config.apiKeys.refreshModal.impact",
"Any applications or services currently using these keys will stop working until you update them with the new keys.",
)}
</Text>
<Text size="sm" fw={500}>
{t(
"config.apiKeys.refreshModal.confirmPrompt",
"Are you sure you want to continue?",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button color="red" onClick={onConfirm}>
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
export function useApiKey() {
const [apiKey, setApiKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
const { t } = useTranslation();
function failedToCreateAlert() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToCreateApiKey",
"Failed to create API key.",
),
isPersistentPopup: false,
});
}
const fetchKey = useCallback(async () => {
setIsLoading(true);
setError(null);
// Backend is POST for get and update
await apiClient
.post("/api/v1/user/get-api-key", undefined, {
responseType: "json",
})
.then((response) => {
const data = response.data;
const apiKeyValue = typeof data === "string" ? data : data?.apiKey;
if (typeof apiKeyValue === "string") {
setApiKey(apiKeyValue);
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRetrieveApiKey",
"Failed to retrieve API key from response.",
),
isPersistentPopup: false,
});
}
})
.catch(async (e) => {
// If not found, try to create one by calling update endpoint
if (e?.response?.status === 404) {
await apiClient
.post("/api/v1/user/update-api-key")
.then((createRes) => {
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
if (typeof created === "string") {
setApiKey(created);
} else {
failedToCreateAlert();
}
})
.catch((createErr) => {
failedToCreateAlert();
setError(createErr);
});
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToFetchApiKey",
"Failed to fetch API key.",
),
isPersistentPopup: false,
});
setError(e);
}
})
.finally(() => {
setIsLoading(false);
setHasAttempted(true);
});
}, []);
const refresh = useCallback(async () => {
setIsRefreshing(true);
setError(null);
await apiClient
.post("/api/v1/user/update-api-key", undefined, {
responseType: "json",
suppressErrorToast: true,
})
.then((res) => {
const value =
typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") {
alert({
alertType: "success",
title: t(
"config.apiKeys.alert.apiKeyRefreshed",
"API Key Refreshed",
),
body: t(
"config.apiKeys.alert.apiKeyRefreshedBody",
"Your API key has been successfully refreshed.",
),
isPersistentPopup: false,
});
setApiKey(value);
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
}
})
.catch((e) => {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
setError(e);
})
.finally(() => {
setIsRefreshing(false);
});
}, []);
useEffect(() => {
if (!hasAttempted) {
fetchKey();
}
}, [hasAttempted, fetchKey]);
return {
apiKey,
isLoading,
isRefreshing,
error,
refetch: fetchKey,
refresh,
hasAttempted,
} as const;
}
export default useApiKey;
@@ -0,0 +1,329 @@
import React, { useState, useEffect } from "react";
import {
Card,
Text,
Group,
Stack,
SegmentedControl,
Loader,
Alert,
Box,
SimpleGrid,
} from "@mantine/core";
import {
AreaChart,
Area,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useTranslation } from "react-i18next";
import auditService, { AuditChartsData } from "@app/services/auditService";
// Event type color mapping
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: "var(--mantine-color-green-6)",
USER_LOGOUT: "var(--mantine-color-gray-5)",
USER_FAILED_LOGIN: "var(--mantine-color-red-6)",
USER_PROFILE_UPDATE: "var(--mantine-color-blue-6)",
SETTINGS_CHANGED: "var(--mantine-color-orange-6)",
FILE_OPERATION: "var(--mantine-color-cyan-6)",
PDF_PROCESS: "var(--mantine-color-violet-6)",
UI_DATA: "var(--mantine-color-teal-6)",
HTTP_REQUEST: "var(--mantine-color-indigo-6)",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || "var(--mantine-color-blue-6)";
};
interface AuditChartsSectionProps {
loginEnabled?: boolean;
timePeriod?: "day" | "week" | "month";
onTimePeriodChange?: (period: "day" | "week" | "month") => void;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
loginEnabled = true,
timePeriod = "week",
onTimePeriodChange,
}) => {
const { t } = useTranslation();
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchChartsData = async () => {
try {
setLoading(true);
setError(null);
const data = await auditService.getChartsData(timePeriod);
setChartsData(data);
} catch (err) {
setError(
err instanceof Error
? err.message
: t("audit.charts.error", "Failed to load charts"),
);
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchChartsData();
} else {
// Demo data when login disabled
setChartsData({
eventsByType: {
labels: [
"LOGIN",
"LOGOUT",
"SETTINGS_CHANGE",
"FILE_UPLOAD",
"FILE_DOWNLOAD",
],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
labels: ["admin", "user1", "user2", "user3", "user4"],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<Group justify="center">
<Loader size="lg" my="xl" />
</Group>
</Card>
);
}
if (error) {
return (
<Alert
color="red"
title={t("audit.charts.error", "Error loading charts")}
>
{error}
</Alert>
);
}
if (!chartsData) {
return null;
}
// Transform data for Recharts
const eventsOverTimeData = chartsData.eventsOverTime.labels.map(
(label, index) => ({
name: label,
value: chartsData.eventsOverTime.values[index],
}),
);
const eventsByTypeData = chartsData.eventsByType.labels.map(
(label, index) => ({
type: label,
value: chartsData.eventsByType.values[index],
}),
);
const eventsByUserData = chartsData.eventsByUser.labels.map(
(label, index) => ({
user: label,
value: chartsData.eventsByUser.values[index],
}),
);
return (
<Stack gap="lg">
{/* Header with time period selector */}
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t("audit.charts.title", "Audit Dashboard")}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
onTimePeriodChange?.(value as "day" | "week" | "month");
}}
disabled={!loginEnabled}
data={[
{ label: t("audit.charts.day", "Day"), value: "day" },
{ label: t("audit.charts.week", "Week"), value: "week" },
{ label: t("audit.charts.month", "Month"), value: "month" },
]}
/>
</Group>
{/* Full-width Events Over Time Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.overTime", "Events Over Time")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsOverTimeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={eventsOverTimeData}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--mantine-color-blue-6)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--mantine-color-blue-6)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="var(--mantine-color-blue-6)"
fillOpacity={1}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">
{t("audit.charts.noData", "No data for this period")}
</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Two-column grid for remaining charts */}
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
{/* Events by Type Chart */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.byType", "Events by Type")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsByTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByTypeData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis
dataKey="type"
stroke="var(--mantine-color-gray-6)"
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
{eventsByTypeData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getEventTypeColor(entry.type)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
{/* Top Users Chart (Horizontal) */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t("audit.charts.byUser", "Top Users")}
</Text>
<Box style={{ width: "100%", height: 280 }}>
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--mantine-color-gray-2)"
/>
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
<YAxis
type="category"
dataKey="user"
stroke="var(--mantine-color-gray-6)"
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
</BarChart>
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
</Stack>
</Card>
</SimpleGrid>
</Stack>
);
};
export default AuditChartsSection;
@@ -0,0 +1,245 @@
import React, { useState } from "react";
import {
Card,
Stack,
Text,
PasswordInput,
Button,
Group,
Alert,
Code,
Badge,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditClearDataSectionProps {
loginEnabled?: boolean;
}
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({
loginEnabled = true,
}) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState("");
const [generatedCode, setGeneratedCode] = useState("");
const [showConfirmation, setShowConfirmation] = useState(false);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleInitiateDeletion = () => {
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
setGeneratedCode(code);
setConfirmationCode("");
setShowConfirmation(true);
setError(null);
};
const resetForm = () => {
setConfirmationCode("");
setGeneratedCode("");
setShowConfirmation(false);
setError(null);
};
const handleClearData = async () => {
if (confirmationCode !== generatedCode) {
setError(t("audit.clearData.codeDoesNotMatch", "Code does not match"));
return;
}
try {
setClearing(true);
setError(null);
await auditService.clearAllAuditData();
setSuccess(true);
resetForm();
// Auto-dismiss success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to clear audit data",
);
} finally {
setClearing(false);
}
};
if (success) {
return (
<Stack gap="lg">
<Alert
color="green"
icon={
<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />
}
title={t("audit.clearData.success", "Success")}
onClose={() => setSuccess(false)}
closeButtonLabel="Close alert"
withCloseButton
>
{t(
"audit.clearData.successMessage",
"All audit data has been cleared successfully",
)}
</Alert>
</Stack>
);
}
if (showConfirmation) {
return (
<Stack gap="lg">
<Alert
color="orange"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t(
"audit.clearData.confirmTitle",
"Please confirm you want to delete",
)}
>
<Text size="sm">
{t(
"audit.clearData.confirmMessage",
"This will permanently remove all audit logs. Enter the confirmation code below to proceed.",
)}
</Text>
</Alert>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<div
style={{
backgroundColor: "var(--mantine-color-gray-0)",
padding: "1rem",
borderRadius: "0.375rem",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={600} c="dimmed" mb="xs">
{t("audit.clearData.confirmationCode", "Confirmation Code")}
</Text>
<Code
style={{
fontSize: "1.5rem",
letterSpacing: "0.15em",
fontWeight: 600,
display: "block",
textAlign: "center",
padding: "0.75rem",
}}
>
{generatedCode}
</Code>
<Text size="xs" c="dimmed" mt="xs">
{t(
"audit.clearData.enterCodeBelow",
"Enter the code exactly as shown above (case-sensitive)",
)}
</Text>
</div>
<PasswordInput
label={t("audit.clearData.enterCode", "Confirmation Code")}
placeholder={t(
"audit.clearData.codePlaceholder",
"Type the code here",
)}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
disabled={!loginEnabled}
error={
confirmationCode && confirmationCode !== generatedCode
? t("audit.clearData.codeDoesNotMatch", "Code does not match")
: false
}
/>
{error && (
<Alert
color="red"
icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}
>
{error}
</Alert>
)}
<Group justify="space-between">
<Button variant="default" onClick={resetForm} disabled={clearing}>
{t("audit.clearData.cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={handleClearData}
loading={clearing}
disabled={
!loginEnabled ||
!generatedCode ||
confirmationCode !== generatedCode
}
>
{t("audit.clearData.deleteButton", "Delete")}
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}
return (
<Stack gap="lg">
<Alert
color="red"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t("audit.clearData.warning1", "This action cannot be undone")}
>
<Text size="sm">
{t(
"audit.clearData.warning2",
"Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.",
)}
</Text>
</Alert>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<Group>
<Text fw={600}>
{t(
"audit.clearData.confirmationRequired",
"Delete All Audit Data",
)}
</Text>
<Badge color="red">
{t("audit.clearData.irreversible", "IRREVERSIBLE")}
</Badge>
</Group>
<Button
color="red"
onClick={handleInitiateDeletion}
disabled={!loginEnabled}
fullWidth
>
{t("audit.clearData.initiateDelete", "Delete All Data")}
</Button>
</Stack>
</Card>
</Stack>
);
};
export default AuditClearDataSection;
@@ -0,0 +1,564 @@
import React, { useState, useEffect } from "react";
import {
Card,
Text,
Group,
Stack,
Button,
Pagination,
Modal,
Code,
Loader,
Alert,
Table,
Badge,
UnstyledButton,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, {
AuditEvent,
AuditFilters,
} from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditEventsTableProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
}
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
}) => {
const { t } = useTranslation();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
const [sortKey, setSortKey] = useState<
"timestamp" | "eventType" | "username" | "ipAddress" | null
>("timestamp");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const showAuthor = capturePdfAuthor;
const showFileHash = captureFileHash;
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters(
{
page: 0,
pageSize: 20,
},
loginEnabled,
);
useEffect(() => {
const fetchEvents = async () => {
try {
setLoading(true);
setError(null);
const response = await auditService.getEvents({
...filters,
page: currentPage - 1,
});
setEvents(response.events);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchEvents();
} else {
// Provide example audit events when login is disabled
const now = new Date();
setEvents([
{
id: "1",
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: "LOGIN",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "User logged in successfully" },
},
{
id: "2",
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: "FILE_UPLOAD",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "Uploaded document.pdf" },
},
{
id: "3",
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: "SETTINGS_CHANGE",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "Modified system settings" },
},
{
id: "4",
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: "FILE_DOWNLOAD",
username: "user2",
ipAddress: "192.168.1.102",
details: { message: "Downloaded report.pdf" },
},
{
id: "5",
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: "LOGOUT",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "User logged out" },
},
]);
setTotalPages(1);
setLoading(false);
}
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => {
handleFilterChange(key, value);
setCurrentPage(1);
};
const handleClearFiltersWithReset = () => {
handleClearFilters();
setCurrentPage(1);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Sort handling
const toggleSort = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey === key) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir("asc");
}
};
const getSortIcon = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey !== key) return "unfold-more";
return sortDir === "asc" ? "expand-less" : "expand-more";
};
// Event type colors
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: "green",
USER_LOGOUT: "gray",
USER_FAILED_LOGIN: "red",
USER_PROFILE_UPDATE: "blue",
SETTINGS_CHANGED: "orange",
FILE_OPERATION: "cyan",
PDF_PROCESS: "violet",
UI_DATA: "gray",
HTTP_REQUEST: "indigo",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || "blue";
};
// Apply sorting to current events
const sortedEvents = [...events].sort((a, b) => {
let aVal: string | number | undefined;
let bVal: string | number | undefined;
switch (sortKey) {
case "timestamp":
aVal = new Date(a.timestamp).getTime();
bVal = new Date(b.timestamp).getTime();
break;
case "eventType":
aVal = a.eventType;
bVal = b.eventType;
break;
case "username":
aVal = a.username;
bVal = b.username;
break;
case "ipAddress":
aVal = a.ipAddress;
bVal = b.ipAddress;
break;
default:
return 0;
}
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.events.title", "Audit Events")}
</Text>
{/* Filters */}
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChangeWithReset}
onClearFilters={handleClearFiltersWithReset}
disabled={!loginEnabled}
/>
{/* Table */}
{loading ? (
<div style={{ display: "flex", justifyContent: "center" }}>
<Loader size="lg" my="xl" />
</div>
) : error ? (
<Alert
color="red"
title={t("audit.events.error", "Error loading events")}
>
{error}
</Alert>
) : (
<>
<div
style={{
overflowX: "auto",
overflowY: "hidden",
marginBottom: "1rem",
}}
>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("timestamp")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.timestamp", "Timestamp")}
<LocalIcon
icon={getSortIcon("timestamp")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("eventType")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.type", "Type")}
<LocalIcon
icon={getSortIcon("eventType")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
padding: "0.5rem",
}}
fz="sm"
>
<UnstyledButton
onClick={() => toggleSort("username")}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
cursor: "pointer",
userSelect: "none",
}}
>
{t("audit.events.user", "User")}
<LocalIcon
icon={getSortIcon("username")}
width="0.9rem"
height="0.9rem"
/>
</UnstyledButton>
</Table.Th>
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.documentName", "Document Name")}
</Table.Th>
{showAuthor && (
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.author", "Author")}
</Table.Th>
)}
{showFileHash && (
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
>
{t("audit.events.fileHash", "File Hash")}
</Table.Th>
)}
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
ta="center"
>
{t("audit.events.actions", "Actions")}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={totalColumns}>
<Group justify="center" py="xl">
<Stack align="center" gap={0}>
<LocalIcon
icon="search"
width="2rem"
height="2rem"
style={{ opacity: 0.4 }}
/>
<Text ta="center" c="dimmed" size="sm">
{t("audit.events.noEvents", "No events found")}
</Text>
</Stack>
</Group>
</Table.Td>
</Table.Tr>
) : (
sortedEvents.map((event) => {
// Extract document name, author, hash from details.files if available
let documentName = "";
let author = "";
let fileHash = "";
if (event.details && typeof event.details === "object") {
const details = event.details as Record<
string,
unknown
>;
const files = details.files;
if (Array.isArray(files) && files.length > 0) {
const firstFile = files[0] as Record<string, unknown>;
documentName =
typeof firstFile.name === "string"
? firstFile.name
: "";
if (showAuthor || showFileHash) {
author =
typeof firstFile.pdfAuthor === "string"
? firstFile.pdfAuthor
: "";
fileHash =
typeof firstFile.fileHash === "string"
? firstFile.fileHash.substring(0, 16) + "..."
: "";
}
}
}
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
size="sm"
color={getEventTypeColor(event.eventType)}
>
{event.eventType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" title={documentName}>
{documentName || "—"}
</Text>
</Table.Td>
{showAuthor && (
<Table.Td>
<Text size="sm">{author}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text
size="sm"
title={fileHash}
style={{
fontFamily: "monospace",
fontSize: "0.75rem",
}}
>
{fileHash}
</Text>
</Table.Td>
)}
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t("audit.events.viewDetails", "View Details")}
</Button>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={setCurrentPage}
total={totalPages}
/>
</Group>
)}
</div>
</>
)}
</Stack>
{/* Event Details Modal */}
<Modal
opened={selectedEvent !== null}
onClose={() => setSelectedEvent(null)}
title={t("audit.events.eventDetails", "Event Details")}
size="lg"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{selectedEvent && (
<Stack gap="md">
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.timestamp", "Timestamp")}
</Text>
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.type", "Type")}
</Text>
<Text size="sm">{selectedEvent.eventType}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.user", "User")}
</Text>
<Text size="sm">{selectedEvent.username}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.ipAddress", "IP Address")}
</Text>
<Text size="sm">{selectedEvent.ipAddress}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t("audit.events.details", "Details")}
</Text>
<Code block mah={300} style={{ overflow: "auto" }}>
{JSON.stringify(selectedEvent.details, null, 2)}
</Code>
</div>
</Stack>
)}
</Modal>
</Card>
);
};
export default AuditEventsTable;
@@ -0,0 +1,269 @@
import React, { useState } from "react";
import {
Card,
Text,
Group,
Stack,
Button,
SegmentedControl,
Checkbox,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
interface AuditExportSectionProps {
loginEnabled?: boolean;
captureFileHash?: boolean;
capturePdfAuthor?: boolean;
captureOperationResults?: boolean;
}
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
loginEnabled = true,
captureFileHash = false,
capturePdfAuthor = false,
captureOperationResults = false,
}) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<"csv" | "json">("csv");
const [exporting, setExporting] = useState(false);
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>(
{
date: true,
username: true,
ipaddress: false,
tool: true,
documentName: true,
outcome: true,
author: capturePdfAuthor,
fileHash: captureFileHash,
operationResults: captureOperationResults,
},
);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters({}, loginEnabled);
const handleExport = async () => {
if (!loginEnabled) return;
try {
setExporting(true);
const fieldsParam = Object.keys(selectedFields)
.filter((k) => selectedFields[k])
.join(",");
const blob = await auditService.exportData(exportFormat, {
...filters,
fields: fieldsParam,
});
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
alert(t("audit.export.error", "Failed to export data"));
} finally {
setExporting(false);
}
};
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.export.title", "Export Audit Data")}
</Text>
<Text size="sm" c="dimmed">
{t(
"audit.export.description",
"Export audit events to CSV or JSON format. Use filters to limit the exported data.",
)}
</Text>
{/* Format Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.format", "Export Format")}
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as "csv" | "json");
}}
disabled={!loginEnabled}
data={[
{ label: "CSV", value: "csv" },
{ label: "JSON", value: "json" },
]}
/>
</div>
{/* Field Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.selectFields", "Select Fields to Include")}
</Text>
<Stack gap="xs">
<Checkbox
label={t("audit.export.fieldDate", "Date")}
checked={selectedFields.date}
onChange={(e) =>
setSelectedFields({
...selectedFields,
date: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldUsername", "Username")}
checked={selectedFields.username}
onChange={(e) =>
setSelectedFields({
...selectedFields,
username: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldIpAddress", "IP Address")}
checked={selectedFields.ipaddress}
onChange={(e) =>
setSelectedFields({
...selectedFields,
ipaddress: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldTool", "Tool")}
checked={selectedFields.tool}
onChange={(e) =>
setSelectedFields({
...selectedFields,
tool: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldDocumentName", "Document Name")}
checked={selectedFields.documentName}
onChange={(e) =>
setSelectedFields({
...selectedFields,
documentName: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t(
"audit.export.fieldOutcome",
"Outcome (Success/Failure)",
)}
checked={selectedFields.outcome}
onChange={(e) =>
setSelectedFields({
...selectedFields,
outcome: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
{capturePdfAuthor && (
<Checkbox
label={t("audit.export.fieldAuthor", "Author (from PDF)")}
checked={selectedFields.author}
onChange={(e) =>
setSelectedFields({
...selectedFields,
author: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
{captureFileHash && (
<Checkbox
label={t("audit.export.fieldFileHash", "File Hash (SHA-256)")}
checked={selectedFields.fileHash}
onChange={(e) =>
setSelectedFields({
...selectedFields,
fileHash: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t(
"audit.export.fieldOperationResults",
"Operation Results",
)}
checked={selectedFields.operationResults}
onChange={(e) =>
setSelectedFields({
...selectedFields,
operationResults: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
</Stack>
</div>
{/* Filters */}
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.export.filters", "Filters (Optional)")}
</Text>
<AuditFiltersForm
filters={filters}
eventTypes={eventTypes}
users={users}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
disabled={!loginEnabled}
/>
</div>
{/* Export Button */}
<Group justify="flex-end">
<Button
leftSection={
<LocalIcon icon="download" width="1rem" height="1rem" />
}
onClick={handleExport}
loading={exporting}
disabled={!loginEnabled || exporting}
>
{t("audit.export.exportButton", "Export Data")}
</Button>
</Group>
</Stack>
</Card>
);
};
export default AuditExportSection;
@@ -0,0 +1,231 @@
import React from "react";
import {
Group,
MultiSelect,
Button,
Stack,
SimpleGrid,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { AuditFilters } from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
const formatDateToYMD = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Helper to calculate date range for quick presets
const getDateRange = (preset: string): [Date, Date] | null => {
const end = new Date();
const start = new Date();
switch (preset) {
case "today":
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
case "last7":
start.setDate(start.getDate() - 6);
return [start, end];
case "last30":
start.setDate(start.getDate() - 29);
return [start, end];
case "thisMonth":
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
default:
return null;
}
};
interface AuditFiltersFormProps {
filters: AuditFilters;
eventTypes: string[];
users: string[];
onFilterChange: (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => void;
onClearFilters: () => void;
disabled?: boolean;
}
/**
* Shared filter form for audit components
*/
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
filters,
eventTypes,
users,
onFilterChange,
onClearFilters,
disabled = false,
}) => {
const { t } = useTranslation();
const handleQuickPreset = (preset: string) => {
const range = getDateRange(preset);
if (range) {
const [start, end] = range;
onFilterChange("startDate", formatDateToYMD(start));
onFilterChange("endDate", formatDateToYMD(end));
}
};
const isPresetActive = (preset: string): boolean => {
if (!filters.startDate || !filters.endDate) return false;
const range = getDateRange(preset);
if (!range) return false;
const [expectedStart, expectedEnd] = range;
const expectedStartStr = formatDateToYMD(expectedStart);
const expectedEndStr = formatDateToYMD(expectedEnd);
return (
filters.startDate === expectedStartStr &&
filters.endDate === expectedEndStr
);
};
return (
<Stack gap="md">
{/* Quick Preset Buttons */}
<div>
<Text size="xs" fw={600} mb="xs" c="dimmed">
{t("audit.filters.quickPresets", "Quick filters")}
</Text>
<Group gap="xs">
<Button
variant={isPresetActive("today") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("today")}
disabled={disabled}
>
{t("audit.filters.today", "Today")}
</Button>
<Button
variant={isPresetActive("last7") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("last7")}
disabled={disabled}
>
{t("audit.filters.last7Days", "Last 7 days")}
</Button>
<Button
variant={isPresetActive("last30") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("last30")}
disabled={disabled}
>
{t("audit.filters.last30Days", "Last 30 days")}
</Button>
<Button
variant={isPresetActive("thisMonth") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset("thisMonth")}
disabled={disabled}
>
{t("audit.filters.thisMonth", "This month")}
</Button>
</Group>
</div>
{/* Filter Inputs */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
<MultiSelect
placeholder={t("audit.events.filterByType", "Filter by type")}
data={eventTypes.map((type) => ({ value: type, label: type }))}
value={
Array.isArray(filters.eventType)
? filters.eventType
: filters.eventType
? [filters.eventType]
: []
}
onChange={(value) =>
onFilterChange("eventType", value.length > 0 ? value : undefined)
}
clearable
disabled={disabled}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<MultiSelect
placeholder={t("audit.events.filterByUser", "Filter by user")}
data={users.map((user) => ({ value: user, label: user }))}
value={
Array.isArray(filters.username)
? filters.username
: filters.username
? [filters.username]
: []
}
onChange={(value) =>
onFilterChange("username", value.length > 0 ? value : undefined)
}
clearable
searchable
disabled={disabled}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<DateInput
placeholder={t("audit.events.startDate", "Start date")}
value={filters.startDate ? new Date(filters.startDate) : null}
onChange={(value) => {
onFilterChange(
"startDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<DateInput
placeholder={t("audit.events.endDate", "End date")}
value={filters.endDate ? new Date(filters.endDate) : null}
onChange={(value) => {
onFilterChange(
"endDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</SimpleGrid>
{/* Clear Button */}
<Group justify="flex-end">
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
disabled={disabled}
>
{t("audit.events.clearFilters", "Clear")}
</Button>
</Group>
</Stack>
);
};
export default AuditFiltersForm;
@@ -0,0 +1,286 @@
import React, { useState, useEffect } from "react";
import {
Card,
Group,
Stack,
Text,
Badge,
SimpleGrid,
Loader,
Alert,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, { AuditStats } from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditStatsCardsProps {
loginEnabled?: boolean;
timePeriod: "day" | "week" | "month";
}
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({
loginEnabled = true,
timePeriod = "week",
}) => {
const { t } = useTranslation();
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
try {
setLoading(true);
setError(null);
const data = await auditService.getStats(timePeriod);
setStats(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load statistics",
);
} finally {
setLoading(false);
}
};
if (loginEnabled) {
fetchStats();
} else {
// Demo data when login disabled
setStats({
totalEvents: 4256,
prevTotalEvents: 3891,
uniqueUsers: 12,
prevUniqueUsers: 10,
successRate: 96.5,
prevSuccessRate: 94.2,
avgLatencyMs: 342,
prevAvgLatencyMs: 385,
errorCount: 148,
topEventType: "PDF_PROCESS",
topUser: "admin",
eventsByType: {},
eventsByUser: {},
topTools: {},
hourlyDistribution: {},
});
setLoading(false);
}
}, [timePeriod, loginEnabled]);
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
</Card>
);
}
if (error) {
return (
<Alert
color="red"
title={t("audit.stats.error", "Error loading statistics")}
>
{error}
</Alert>
);
}
if (!stats) {
return null;
}
const trendPercent =
stats.prevTotalEvents > 0
? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) *
100
: 0;
const userTrend =
stats.prevUniqueUsers > 0
? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) *
100
: 0;
const latencyTrend =
stats.prevAvgLatencyMs > 0
? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) /
stats.prevAvgLatencyMs) *
100
: 0;
const successTrend =
stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
const getSuccessRateColor = (rate: number) => {
if (rate >= 95) return "green";
if (rate >= 80) return "yellow";
return "red";
};
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
if (lowerIsBetter) {
return trend <= 0 ? "green" : "red";
}
return trend >= 0 ? "green" : "red";
};
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
return isPositive ? "trending-up" : "trending-down";
};
return (
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{/* Total Events Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.totalEvents", "Total Events")}
</Text>
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.totalEvents.toLocaleString()}
</Text>
{trendPercent !== 0 && (
<Badge
color={getTrendColor(trendPercent)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(trendPercent)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(trendPercent).toFixed(1)}%{" "}
{t("audit.stats.vsLastPeriod", "vs last period")}
</Badge>
)}
</Stack>
</Card>
{/* Success Rate Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.successRate", "Success Rate")}
</Text>
<LocalIcon
icon="check-circle-rounded"
width="1.2rem"
height="1.2rem"
/>
</Group>
<Text size="xl" fw={700}>
{stats.successRate.toFixed(1)}%
</Text>
<Group gap="xs">
<Badge
color={getSuccessRateColor(stats.successRate)}
variant="light"
size="sm"
>
{stats.successRate >= 95
? t("audit.stats.excellent", "Excellent")
: stats.successRate >= 80
? t("audit.stats.good", "Good")
: t("audit.stats.attention", "Attention needed")}
</Badge>
{successTrend !== 0 && (
<Badge
color={getTrendColor(successTrend)}
variant="light"
size="xs"
>
{successTrend > 0 ? "+" : ""}
{successTrend.toFixed(1)}%
</Badge>
)}
</Group>
</Stack>
</Card>
{/* Active Users Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.activeUsers", "Active Users")}
</Text>
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.uniqueUsers}
</Text>
{userTrend !== 0 && (
<Badge
color={getTrendColor(userTrend)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(userTrend)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(userTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
{/* Avg Latency Card */}
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t("audit.stats.avgLatency", "Avg Latency")}
</Text>
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.avgLatencyMs > 0
? `${stats.avgLatencyMs.toFixed(0)}ms`
: t("audit.stats.noData", "N/A")}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
color={getTrendColor(latencyTrend, true)}
variant="light"
size="sm"
leftSection={
<LocalIcon
icon={getTrendIcon(latencyTrend, true)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(latencyTrend).toFixed(1)}%
</Badge>
)}
</Stack>
</Card>
</SimpleGrid>
);
};
export default AuditStatsCards;
@@ -0,0 +1,137 @@
import React from "react";
import { Card, Group, Stack, Badge, Text, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AuditSystemStatus as AuditStatus } from "@app/services/auditService";
interface AuditSystemStatusProps {
status: AuditStatus;
}
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("audit.systemStatus.title", "System Status")}
</Text>
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.status", "Audit Logging")}
</Text>
<Badge
color={status.enabled ? "green" : "red"}
variant="light"
size="lg"
mt="xs"
>
{status.enabled
? t("audit.systemStatus.enabled", "Enabled")
: t("audit.systemStatus.disabled", "Disabled")}
</Badge>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.level", "Audit Level")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.level}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.retention", "Retention Period")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.retentionDays} {t("audit.systemStatus.days", "days")}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("audit.systemStatus.totalEvents", "Total Events")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.totalEvents.toLocaleString()}
</Text>
</div>
</Group>
<Divider />
<div>
<Text size="sm" fw={600} mb="xs">
{t("audit.systemStatus.capturedFields", "Captured Fields")}
</Text>
<Group gap="xs">
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.username", "Username")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.documentName", "Document Name")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.tool", "Tool")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.date", "Date")}
</Badge>
<Badge
color={status.capturePdfAuthor ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.pdfAuthor", "PDF Author")}
{!status.capturePdfAuthor && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
<Badge
color={status.captureFileHash ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.fileHash", "File Hash")}
{!status.captureFileHash && (
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
</Group>
</div>
</Stack>
</Card>
);
};
export default AuditSystemStatus;
@@ -0,0 +1,150 @@
import React, { useState, useMemo } from "react";
import { Button, Collapse, Select, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService, {
PlanTier,
PlanTierGroup,
LicenseInfo,
mapLicenseToTier,
} from "@app/services/licenseService";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
} from "@app/utils/planTierUtils";
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: { value: string; label: string }[];
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
onManageClick,
currency,
onCurrencyChange,
currencyOptions,
loginEnabled = true,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Group plans by tier (Free, Server, Enterprise)
const groupedPlans = useMemo(() => {
return licenseService.groupPlansByTier(plans);
}, [plans]);
// Calculate current tier from license info
const currentTier = useMemo(() => {
return mapLicenseToTier(currentLicenseInfo || null);
}, [currentLicenseInfo]);
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
return checkIsCurrentTier(currentTier, tierGroup.tier);
};
// Determine if selecting this plan would be a downgrade
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
return checkIsDowngrade(currentTier, tierGroup.tier);
};
return (
<div>
<Group justify="space-between" align="flex-start" mb="xs">
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 0 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.availablePlans.subtitle",
"Choose the plan that fits your needs",
)}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || "usd")}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
disabled={!loginEnabled}
/>
)}
</Group>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
marginBottom: "0.1rem",
}}
>
{groupedPlans.map((group) => (
<PlanCard
key={group.tier}
planGroup={group}
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
currentTier={currentTier}
onUpgradeClick={onUpgradeClick}
onManageClick={onManageClick}
loginEnabled={loginEnabled}
/>
))}
</div>
<div style={{ textAlign: "center" }}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable
plans={groupedPlans}
currentTier={currentTier}
/>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,107 @@
import React from "react";
import { Card, Badge, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanFeature } from "@app/services/licenseService";
interface PlanWithFeatures {
name: string;
features: PlanFeature[];
popular?: boolean;
tier?: string;
}
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: "free" | "server" | "enterprise" | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
plans,
currentTier,
}) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder style={{ marginTop: "1rem" }}>
<Text size="lg" fw={600} mb="md">
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr
style={{ borderBottom: "2px solid var(--mantine-color-gray-3)" }}
>
<th style={{ textAlign: "left", padding: "0.75rem" }}>
{t("plan.feature.title", "Feature")}
</th>
{plans.map((plan, index) => (
<th
key={plan.tier || plan.name || index}
style={{
textAlign: "center",
padding: "0.75rem",
minWidth: "8rem",
position: "relative",
}}
>
{plan.name}
{plan.popular &&
!(
plan.tier === "server" && currentTier === "enterprise"
) && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: "absolute",
top: "0.5rem",
right: "0.5rem",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0]?.features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<td style={{ padding: "0.75rem" }}>
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan, planIndex) => (
<td
key={planIndex}
style={{ textAlign: "center", padding: "0.75rem" }}
>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
</Text>
) : (
<Text c="gray" size="sm">
</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};
export default FeatureComparisonTable;
@@ -0,0 +1,359 @@
import React, { useState } from "react";
import {
Button,
Collapse,
Alert,
TextInput,
Paper,
Stack,
Group,
Text,
SegmentedControl,
FileButton,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { LicenseInfo } from "@app/services/licenseService";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>("");
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<"text" | "file">("text");
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === "file" && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === "text" && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key or file",
),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage =
inputMethod === "file"
? t(
"admin.settings.premium.file.successMessage",
"License file uploaded and activated successfully",
)
: t(
"admin.settings.premium.key.successMessage",
"License key activated successfully",
);
alert({
alertType: "success",
title: t("success", "Success"),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput("");
setLicenseFile(null);
setInputMethod("text"); // Reset to default
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error("Failed to save license:", error);
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
}
};
return (
<div>
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={
showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"
}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t(
"admin.settings.premium.licenseKey.toggle",
"Got a license key or certificate file?",
)}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.licenseKey.info",
"If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.",
)}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={
<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />
}
title={t(
"admin.settings.premium.key.overwriteWarning.title",
"⚠️ Warning: Existing License Detected",
)}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
"admin.settings.premium.key.overwriteWarning.line1",
"Overwriting your current license key cannot be undone.",
)}
</Text>
<Text size="sm">
{t(
"admin.settings.premium.key.overwriteWarning.line2",
"Your previous license will be permanently lost unless you have backed it up elsewhere.",
)}
</Text>
<Text size="sm" fw={500}>
{t(
"admin.settings.premium.key.overwriteWarning.line3",
"Important: Keep license keys private and secure. Never share them publicly.",
)}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t(
"admin.settings.premium.currentLicense.title",
"Active License",
)}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith("file:")
? t(
"admin.settings.premium.currentLicense.file",
"Source: License file ({{path}})",
{
path: currentLicenseInfo.licenseKey.substring(5),
},
)
: t(
"admin.settings.premium.currentLicense.key",
"Source: License key",
)}
</Text>
<Text size="xs">
{t(
"admin.settings.premium.currentLicense.type",
"Type: {{type}}",
{
type: currentLicenseInfo.licenseType,
},
)}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as "text" | "file");
// Clear opposite input when switching
if (value === "text") setLicenseFile(null);
if (value === "file") setLicenseKeyInput("");
}}
data={[
{
label: t(
"admin.settings.premium.inputMethod.text",
"License Key",
),
value: "text",
},
{
label: t(
"admin.settings.premium.inputMethod.file",
"Certificate File",
),
value: "file",
},
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === "text" ? (
/* Text input */
<TextInput
label={t("admin.settings.premium.key.label", "License Key")}
description={t(
"admin.settings.premium.key.description",
"Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={
currentLicenseInfo?.licenseKey ||
"00000000-0000-0000-0000-000000000000"
}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t(
"admin.settings.premium.file.label",
"License Certificate File",
)}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t(
"admin.settings.premium.file.description",
"Upload your .lic or .cert license file",
)}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={
<LocalIcon
icon="upload-file-rounded"
width="1rem"
height="1rem"
/>
}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t(
"admin.settings.premium.file.choose",
"Choose License File",
)}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t(
"admin.settings.premium.file.selected",
"Selected: {{filename}} ({{size}})",
{
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + " KB",
},
)}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === "text" && !licenseKeyInput.trim()) ||
(inputMethod === "file" && !licenseFile)
}
>
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;
@@ -0,0 +1,222 @@
import React from "react";
import { Button, Card, Text, Stack, Divider, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup, LicenseInfo } from "@app/services/licenseService";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import { calculateDisplayPricing } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from "@app/utils/planTierUtils";
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
currentTier?: "free" | "server" | "enterprise" | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
planGroup,
isCurrentTier,
isDowngrade,
currentLicenseInfo,
currentTier,
onUpgradeClick,
onManageClick,
loginEnabled = true,
}) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === "free") {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || "$";
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
{isCurrentTier && (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
)}
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t("plan.from", "From")}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t("plan.free.forever", "Forever free")}
/>
</div>
<Divider />
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth className="plan-button">
{isCurrentTier
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
</Stack>
</Card>
);
}
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === "enterprise";
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(
currentTier,
planGroup.tier,
);
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } =
calculateDisplayPricing(monthly || undefined, yearly || undefined);
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
{isCurrentTier ? (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
) : planGroup.popular &&
!(planGroup.tier === "server" && currentTier === "enterprise") ? (
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
) : null}
<Stack gap="md" style={{ height: "100%" }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("plan.from", "From")}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}
{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t("plan.perSeat", "/seat")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("plan.perMonth", "/month")}{" "}
{t("plan.withServer", "+ Server Plan")}
</Text>
</>
) : (
<PriceDisplay
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t("plan.perMonth", "/month")}
/>
)}
</div>
<Divider />
{/* Highlights */}
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise &&
isCurrentTier &&
currentLicenseInfo &&
currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t("plan.licensedSeats", "Licensed: {{count}} seats", {
count: currentLicenseInfo.maxUsers,
})}
</Text>
)}
{/* Single Upgrade Button */}
<Tooltip
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
<Button
variant="filled"
fullWidth
onClick={() =>
isCurrentTier && onManageClick
? onManageClick()
: onUpgradeClick(planGroup)
}
disabled={
!loginEnabled || isDowngrade || isEnterpriseBlockedForFree
}
className="plan-button"
>
{isCurrentTier
? t("plan.manage", "Manage")
: isDowngrade
? t("plan.free.included", "Included")
: isEnterpriseBlockedForFree
? t("plan.enterprise.requiresServer", "Requires Server")
: isEnterprise
? t("plan.selectPlan", "Select Plan")
: t("plan.upgrade", "Upgrade")}
</Button>
</Tooltip>
</Stack>
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,392 @@
import React, { useState } from "react";
import {
Modal,
Text,
Group,
ActionIcon,
Stack,
Paper,
Grid,
TextInput,
Button,
Alert,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
STATIC_STRIPE_LINKS,
buildStripeUrlWithEmail,
} from "@app/constants/staticStripeLinks";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useIsMobile } from "@app/hooks/useIsMobile";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: "server" | "enterprise";
isUpgrade?: boolean;
}
type Stage = "email" | "period-selection" | "license-activation";
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>("email");
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState("");
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError("");
setStageHistory([...stageHistory, "email"]);
setStage("period-selection");
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: "monthly" | "yearly") => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, "_blank");
// Transition to license activation stage
setStageHistory([...stageHistory, "period-selection"]);
setStage("license-activation");
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key",
),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: "success",
title: t("success", "Success"),
body: t(
"admin.settings.premium.key.successMessage",
"License key activated successfully",
),
});
} else {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error("Failed to save license:", error);
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
}
};
const handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage("email");
setEmail("");
setEmailError("");
setStageHistory([]);
setLicenseKey("");
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === "email") {
if (isUpgrade) {
return t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
return planName === "server"
? t("plan.static.getLicense", "Get Server License")
: t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
if (stage === "period-selection") {
return t("plan.static.selectPeriod", "Select Billing Period");
}
if (stage === "license-activation") {
return t("plan.static.activateLicense", "Activate Your License");
}
return "";
};
const renderContent = () => {
switch (stage) {
case "email":
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case "period-selection":
return (
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("monthly")}
>
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.monthly", "Monthly")}
</Text>
<Text size="sm" c="dimmed">
{t("plan.static.monthlyBilling", "Monthly Billing")}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("yearly")}
>
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
<Text size="sm" c="dimmed">
{t("plan.static.yearlyBilling", "Yearly Billing")}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case "license-activation":
return (
<Stack
gap="lg"
style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}
>
<Alert
variant="light"
color="blue"
icon={
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t(
"plan.static.licenseActivation.checkoutOpened",
"Checkout Opened in New Tab",
)}
</Text>
<Text size="sm">
{t(
"plan.static.licenseActivation.instructions",
"Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.",
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
title={t(
"plan.static.licenseActivation.success",
"License Activated!",
)}
>
<Text size="sm">
{t(
"plan.static.licenseActivation.successMessage",
"Your license has been successfully activated. You can now close this window.",
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
"plan.static.licenseActivation.enterKey",
"Enter your license key below to activate your plan:",
)}
</Text>
<TextInput
label={t("admin.settings.premium.key.label", "License Key")}
description={t(
"plan.static.licenseActivation.keyDescription",
"Paste the license key from your email",
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button
variant="subtle"
onClick={handleClose}
disabled={savingLicense}
>
{t(
"plan.static.licenseActivation.doLater",
"I'll do this later",
)}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t(
"plan.static.licenseActivation.activate",
"Activate License",
)}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t("common.close", "Close")}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== "license-activation";
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? "100%" : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;
@@ -0,0 +1,376 @@
import React, { useState } from "react";
import {
Card,
Text,
Stack,
Button,
Collapse,
Divider,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { STATIC_STRIPE_LINKS } from "@app/constants/staticStripeLinks";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
isEnterpriseBlockedForFree,
} from "@app/utils/planTierUtils";
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<"server" | "enterprise">(
"server",
);
const [isUpgrade, setIsUpgrade] = useState(false);
const handleOpenCheckout = (
plan: "server" | "enterprise",
upgrade: boolean,
) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === "free" && plan === "enterprise") {
alert({
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
}
setSelectedPlan(plan);
setIsUpgrade(upgrade);
setCheckoutModalOpened(true);
};
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: "warning",
title: t(
"plan.static.billingPortal.title",
"Email Verification Required",
),
body: t(
"plan.static.billingPortal.message",
"You will need to verify your email address in the Stripe billing portal. Check your email for a login link.",
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, "_blank");
};
const staticPlans = [
{
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: "server",
name: "Server",
price: 0,
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: "Unlimited users",
},
{
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: "Custom",
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === "enterprise") return staticPlans[2];
if (tier === "server") return staticPlans[1];
return staticPlans[0]; // free
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Available Plans */}
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.static.contactToUpgrade",
"Contact us to upgrade or customize your plan",
)}
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
paddingBottom: "0.1rem",
}}
>
{staticPlans.map((plan) => (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<PricingBadge
type="popular"
label={t("plan.popular", "Popular")}
/>
)}
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} style={{ fontSize: "2rem" }}>
{plan.name}
</Text>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === "string"
? plan.maxUsers
: `${t("plan.static.upTo", "Up to")} ${plan.maxUsers} ${t("workspace.people.license.users", "users")}`}
</Text>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(
currentLicenseInfo || null,
);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(
currentTier,
plan.id,
);
// Free Plan
if (plan.id === "free") {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
);
}
// Server Plan
if (plan.id === "server") {
if (currentTier === "free") {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout("server", false)}
className="plan-button"
>
{t("plan.upgrade", "Upgrade")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t("plan.manage", "Manage")}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t("plan.free.included", "Included")}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === "enterprise") {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip
label={t(
"plan.enterprise.requiresServer",
"Requires Server plan",
)}
position="top"
withArrow
>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t(
"plan.enterprise.requiresServer",
"Requires Server",
)}
</Button>
</Tooltip>
);
}
if (currentTier === "server") {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t("plan.contact", "Contact Us")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t("plan.manage", "Manage")}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
{/* Feature Comparison Table */}
<Collapse in={showComparison}>
<FeatureComparisonTable plans={staticPlans} />
</Collapse>
</div>
<Divider />
{/* License Key Section */}
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);
};
export default StaticPlanSection;
@@ -0,0 +1,97 @@
import React from "react";
import { Card, Text, Stack, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface SimpleBarChartProps {
data: { label: string; value: number }[];
maxValue: number;
}
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
{data.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("usage.noData", "No data available")}
</Text>
) : (
data.map((item, index) => (
<Box key={index}>
<Group justify="space-between" mb={4}>
<Text
size="xs"
c="dimmed"
maw="60%"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
</Text>
<Group gap="xs">
<Text size="xs" fw={600}>
{item.value}
</Text>
<Text size="xs" c="dimmed">
({((item.value / maxValue) * 100).toFixed(1)}%)
</Text>
</Group>
</Group>
<Box
style={{
width: "100%",
height: "0.5rem",
backgroundColor: "var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-sm)",
overflow: "hidden",
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: "100%",
backgroundColor: "var(--mantine-color-blue-6)",
transition: "width 0.3s ease",
}}
/>
</Box>
</Box>
))
)}
</Stack>
);
};
interface UsageAnalyticsChartProps {
data: { label: string; value: number }[];
}
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
const { t } = useTranslation();
const safeMaxValue = Math.max(
...data.map((d) => d.value).filter((value) => Number.isFinite(value)),
1,
);
const safeData = data.map((item) => ({
label: item.label,
value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0,
}));
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("usage.chart.title", "Endpoint Usage Chart")}
</Text>
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
</Stack>
</Card>
);
};
export default UsageAnalyticsChart;
@@ -0,0 +1,129 @@
import React from "react";
import {
Card,
Text,
Stack,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EndpointStatistic } from "@app/services/usageAnalyticsService";
interface UsageAnalyticsTableProps {
data: EndpointStatistic[];
}
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t("usage.table.title", "Detailed Statistics")}
</Text>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<TableThead>
<TableTr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="5%"
>
#
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="55%"
>
{t("usage.table.endpoint", "Endpoint")}
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="20%"
ta="right"
>
{t("usage.table.visits", "Visits")}
</TableTh>
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="20%"
ta="right"
>
{t("usage.table.percentage", "Percentage")}
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.length === 0 ? (
<TableTr>
<TableTd colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t("usage.table.noData", "No data available")}
</Text>
</TableTd>
</TableTr>
) : (
data.map((stat, index) => (
<TableTr key={index}>
<TableTd>
<Text size="sm" c="dimmed">
{index + 1}
</Text>
</TableTd>
<TableTd>
<Text size="sm" truncate>
{stat.endpoint}
</Text>
</TableTd>
<TableTd ta="right">
<Text size="sm" fw={600}>
{stat.visits.toLocaleString()}
</Text>
</TableTd>
<TableTd ta="right">
<Text size="sm" c="dimmed">
{stat.percentage.toFixed(2)}%
</Text>
</TableTd>
</TableTr>
))
)}
</TableTbody>
</Table>
</Stack>
</Card>
);
};
export default UsageAnalyticsTable;
@@ -0,0 +1,63 @@
.text-divider {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
margin-top: 0.375rem; /* 6px */
margin-bottom: 0.5rem; /* 8px */
}
.text-divider .text-divider__rule {
height: 0.0625rem; /* 1px */
flex: 1 1 0%;
background-color: rgb(
var(--text-divider-rule-rgb, var(--gray-200)) /
var(--text-divider-opacity, 1)
);
}
.text-divider .text-divider__label {
color: rgb(
var(--text-divider-label-rgb, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
font-size: 0.75rem; /* 12px */
white-space: nowrap;
}
.text-divider.subcategory {
margin-top: 0;
margin-bottom: 0;
}
.text-divider.subcategory .text-divider__rule {
background-color: var(--tool-subcategory-rule-color);
}
.text-divider.subcategory .text-divider__label {
color: var(--tool-subcategory-text-color);
text-transform: uppercase;
font-weight: 600;
}
/* Force light theme colors regardless of dark mode */
.text-divider.force-light .text-divider__rule {
background-color: rgb(
var(--text-divider-rule-rgb-light, var(--gray-200)) /
var(--text-divider-opacity, 1)
);
}
.text-divider.force-light .text-divider__label {
color: rgb(
var(--text-divider-label-rgb-light, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
}
.text-divider.subcategory.force-light .text-divider__rule {
background-color: var(--tool-subcategory-rule-color-light);
}
.text-divider.subcategory.force-light .text-divider__label {
color: var(--tool-subcategory-text-color-light);
}
@@ -0,0 +1,69 @@
import { BASE_PATH } from "@app/constants/app";
import { getLogoFolder } from "@app/constants/logo";
import type { LogoVariant } from "@app/services/preferencesService";
import type { TFunction } from "i18next";
export type LoginCarouselSlide = {
src: string;
alt?: string;
title?: string;
subtitle?: string;
cornerModelUrl?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
export const buildLoginSlides = (
variant: LogoVariant | null | undefined,
t: TFunction,
): LoginCarouselSlide[] => {
const folder = getLogoFolder(variant);
const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
return [
{
src: heroImage,
alt: t("login.slides.overview.alt", "Stirling PDF overview"),
title: t(
"login.slides.overview.title",
"Your one-stop-shop for all your PDF needs.",
),
subtitle: t(
"login.slides.overview.subtitle",
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/AddToPDF.png`,
alt: t("login.slides.edit.alt", "Edit PDFs"),
title: t(
"login.slides.edit.title",
"Edit PDFs to display/secure the information you want",
),
subtitle: t(
"login.slides.edit.subtitle",
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/SecurePDF.png`,
alt: t("login.slides.secure.alt", "Secure PDFs"),
title: t(
"login.slides.secure.title",
"Protect sensitive information in your PDFs",
),
subtitle: t(
"login.slides.secure.subtitle",
"Add passwords, redact content, and manage certificates with ease.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
];
};
export default buildLoginSlides;
@@ -0,0 +1,311 @@
import React, { useEffect } from "react";
import { Modal, Text, Group, ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import licenseService from "@app/services/licenseService";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { StripeCheckoutProps } from "@app/components/shared/stripeCheckout/types/checkout";
import {
validateEmail,
getModalTitle,
} from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { calculateSavings } from "@app/components/shared/stripeCheckout/utils/savingsCalculator";
import { useCheckoutState } from "@app/components/shared/stripeCheckout/hooks/useCheckoutState";
import { useCheckoutNavigation } from "@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation";
import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/useLicensePolling";
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
"VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. " +
"Please add it to your .env file. " +
"Get your key from https://dashboard.stripe.com/apikeys",
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith("pk_")) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`,
);
}
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
// Initialize all state via custom hook
const checkoutState = useCheckoutState(planGroup);
// Initialize navigation hooks
const navigation = useCheckoutNavigation(
checkoutState.state,
checkoutState.setState,
checkoutState.stageHistory,
checkoutState.setStageHistory,
);
// Initialize license polling hook
const polling = useLicensePolling(
checkoutState.isMountedRef,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
onLicenseActivated,
);
// Initialize checkout session hook
const session = useCheckoutSession(
checkoutState.selectedPlan,
checkoutState.state,
checkoutState.setState,
checkoutState.installationId,
checkoutState.setInstallationId,
checkoutState.currentLicenseKey,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
minimumSeats,
polling.pollForLicenseKey,
onSuccess,
onError,
onLicenseActivated,
);
// Calculate savings
const savings = calculateSavings(planGroup, minimumSeats);
// Email submission handler
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState((prev) => ({
...prev,
email: checkoutState.emailInput,
}));
navigation.goToStage("plan-selection");
} else {
checkoutState.setEmailError(validation.error);
}
};
// Plan selection handler
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage("payment");
};
// Close handler
const handleClose = () => {
// Clear any active polling
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
checkoutState.resetState();
onClose();
};
// Cleanup on unmount
useEffect(() => {
checkoutState.isMountedRef.current = true;
return () => {
checkoutState.isMountedRef.current = false;
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
};
}, [checkoutState.isMountedRef, checkoutState.pollingTimeoutRef]);
// Initialize stage based on existing license
useEffect(() => {
if (!opened) return;
// Handle hosted checkout success - open directly to success state
if (hostedCheckoutSuccess) {
console.log("Opening modal to success state for hosted checkout return");
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
checkoutState.setCurrentLicenseKey("existing"); // Flag to indicate upgrade
checkoutState.setPollingStatus("ready");
} else if (hostedCheckoutSuccess.licenseKey) {
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
checkoutState.setPollingStatus("ready");
}
// Set to success state to show success UI
checkoutState.setState({ currentStage: "success", loading: false });
return;
}
// Check for existing license to skip email stage
const checkExistingLicense = async () => {
try {
const licenseInfo = await licenseService.getLicenseInfo();
// Only skip email if license is PRO or ENTERPRISE (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// Has valid premium license - skip email stage
console.log("Valid premium license detected - skipping email stage");
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey || null);
checkoutState.setState({
currentStage: "plan-selection",
loading: false,
});
} else {
// No valid premium license - start at email stage
checkoutState.setState({ currentStage: "email", loading: false });
}
} catch (error) {
console.warn("Could not check for existing license:", error);
// Default to email stage if check fails
checkoutState.setState({ currentStage: "email", loading: false });
}
};
checkExistingLicense();
}, [
opened,
hostedCheckoutSuccess,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
checkoutState.setState,
]);
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (
checkoutState.state.currentStage === "payment" &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
session.createCheckoutSession();
}
}, [
checkoutState.state.currentStage,
checkoutState.state.clientSecret,
checkoutState.state.loading,
session,
]);
// Render stage content
const renderContent = () => {
// Don't block checkout - hosted mode works without publishable key
// The checkout will automatically redirect to Stripe hosted page if key is missing
switch (checkoutState.state.currentStage) {
case "email":
return (
<EmailStage
emailInput={checkoutState.emailInput}
setEmailInput={checkoutState.setEmailInput}
emailError={checkoutState.emailError}
onSubmit={handleEmailSubmit}
/>
);
case "plan-selection":
return (
<PlanSelectionStage
planGroup={planGroup}
minimumSeats={minimumSeats}
savings={savings}
onSelectPlan={handlePlanSelect}
/>
);
case "payment":
return (
<PaymentStage
clientSecret={checkoutState.state.clientSecret || null}
selectedPlan={checkoutState.selectedPlan}
onPaymentComplete={session.handlePaymentComplete}
/>
);
case "success":
return (
<SuccessStage
pollingStatus={checkoutState.pollingStatus}
currentLicenseKey={checkoutState.currentLicenseKey}
licenseKey={checkoutState.licenseKey}
onClose={handleClose}
/>
);
case "error":
return (
<ErrorStage
error={checkoutState.state.error || "An unknown error occurred"}
onClose={handleClose}
/>
);
default:
return null;
}
};
const canGoBack = checkoutState.stageHistory.length > 0;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={navigation.goBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
</Text>
</Group>
}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {},
content: {
maxHeight: "95vh",
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
@@ -0,0 +1,101 @@
import React from "react";
import { Text, Stack } from "@mantine/core";
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { PRICE_FONT_WEIGHT } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface SimplePriceProps {
mode: "simple";
price: number;
currency: string;
period: string;
size?: string;
}
interface EnterprisePriceProps {
mode: "enterprise";
basePrice: number;
seatPrice: number;
totalPrice?: number;
currency: string;
period: "month" | "year";
seatCount?: number;
size?: "sm" | "md" | "lg";
}
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
if (props.mode === "simple") {
const fontSize = props.size || "2.25rem";
return (
<>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(props.price, props.currency)}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{props.period}
</Text>
</>
);
}
// Enterprise mode
const {
basePrice,
seatPrice,
totalPrice,
currency,
period,
seatCount,
size = "md",
} = props;
const fontSize = size === "lg" ? "2rem" : size === "sm" ? "md" : "xl";
const totalFontSize = size === "lg" ? "2rem" : "2rem";
return (
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed" mb="xs">
Base Price
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(basePrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/{period}
</Text>
</Text>
</div>
<div>
<Text size="sm" c="dimmed" mb="xs">
Per Seat
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(seatPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/seat/{period}
</Text>
</Text>
</div>
{totalPrice !== undefined && seatCount && (
<div>
<Text size="sm" c="dimmed" mb="xs">
Total ({seatCount} seats)
</Text>
<Text
size={totalFontSize}
fw={PRICE_FONT_WEIGHT}
style={{ lineHeight: 1 }}
>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
/{period === "year" ? "month" : period}
</Text>
</Text>
</div>
)}
</Stack>
);
};
@@ -0,0 +1,25 @@
import React from "react";
import { Badge } from "@mantine/core";
interface PricingBadgeProps {
type: "current" | "popular" | "savings";
label: string;
savingsPercent?: number;
}
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
const color = type === "current" || type === "savings" ? "green" : "blue";
const size = type === "savings" ? "lg" : "sm";
return (
<Badge
color={color}
variant="filled"
size={size}
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
className={type === "current" ? "current-plan-badge" : undefined}
>
{label}
</Badge>
);
};
@@ -0,0 +1,44 @@
import { useCallback } from "react";
import {
CheckoutState,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Stage navigation and history management hook
*/
export const useCheckoutNavigation = (
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
stageHistory: CheckoutStage[],
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>,
) => {
const goToStage = useCallback(
(nextStage: CheckoutStage) => {
setStageHistory((prev) => [...prev, state.currentStage]);
setState((prev) => ({ ...prev, currentStage: nextStage }));
},
[state.currentStage, setState, setStageHistory],
);
const goBack = useCallback(() => {
if (stageHistory.length > 0) {
const previousStage = stageHistory[stageHistory.length - 1];
setStageHistory((prev) => prev.slice(0, -1));
// Reset payment state when going back from payment stage
if (state.currentStage === "payment") {
setState((prev) => ({
...prev,
currentStage: previousStage,
clientSecret: undefined,
loading: false,
}));
} else {
setState((prev) => ({ ...prev, currentStage: previousStage }));
}
}
}, [stageHistory, state.currentStage, setState, setStageHistory]);
return { goToStage, goBack };
};
@@ -0,0 +1,174 @@
import { useCallback } from "react";
import licenseService, { PlanTier } from "@app/services/licenseService";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
import {
CheckoutState,
PollingStatus,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Checkout session creation and payment handling hook
*/
export const useCheckoutSession = (
selectedPlan: PlanTier | null,
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
installationId: string | null,
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
currentLicenseKey: string | null,
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
minimumSeats: number,
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const createCheckoutSession = useCallback(async () => {
if (!selectedPlan) {
setState({
currentStage: "error",
error: "Selected plan period is not available",
loading: false,
});
return;
}
try {
setState((prev) => ({ ...prev, loading: true }));
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
if (!fetchedInstallationId) {
fetchedInstallationId = await licenseService.getInstallationId();
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
// Only include if it's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (
licenseInfo?.licenseType &&
licenseInfo.licenseType !== "NORMAL" &&
licenseInfo.licenseKey
) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
console.log("Found existing valid license for upgrade");
}
} catch (error) {
console.warn(
"Could not fetch license info, proceeding as new license:",
error,
);
}
const response = await licenseService.createCheckoutSession({
lookup_key: selectedPlan.lookupKey,
installation_id: fetchedInstallationId,
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
email: state.email, // Pass collected email from Stage 1
});
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log("Redirecting to Stripe hosted checkout:", response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState((prev) => ({
...prev,
clientSecret: response.clientSecret,
sessionId: response.sessionId,
loading: false,
}));
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: "Failed to create checkout session";
setState({
currentStage: "error",
error: errorMessage,
loading: false,
});
onError?.(errorMessage);
}
}, [
selectedPlan,
state.email,
installationId,
minimumSeats,
setState,
setInstallationId,
setCurrentLicenseKey,
onError,
]);
const handlePaymentComplete = useCallback(async () => {
// Preserve state when changing stage
setState((prev) => ({ ...prev, currentStage: "success" }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log("Upgrade detected - resyncing existing license with Keygen");
setPollingStatus("polling");
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
onActivated: onLicenseActivated,
});
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus("ready");
} else {
console.error("Failed to sync upgraded license:", activation.error);
setPollingStatus("timeout");
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || "");
} else {
// NEW PLAN FLOW: Poll for new license key
console.log("New subscription - polling for license key");
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || "");
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || "");
}
}
}, [
currentLicenseKey,
installationId,
state.sessionId,
setState,
setPollingStatus,
pollForLicenseKey,
onSuccess,
onLicenseActivated,
]);
return {
createCheckoutSession,
handlePaymentComplete,
};
};
@@ -0,0 +1,83 @@
import { useState, useCallback, useRef } from "react";
import { PlanTierGroup } from "@app/services/licenseService";
import {
CheckoutState,
PollingStatus,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Centralized state management hook for checkout flow
*/
export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [state, setState] = useState<CheckoutState>({
currentStage: "email",
loading: false,
});
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
const [emailInput, setEmailInput] = useState<string>("");
const [emailError, setEmailError] = useState<string>("");
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
planGroup.yearly ? "yearly" : "monthly",
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
null,
);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
// Refs for polling cleanup
const isMountedRef = useRef(true);
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan =
selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const resetState = useCallback(() => {
setState({
currentStage: "email",
loading: false,
clientSecret: undefined,
sessionId: undefined,
error: undefined,
});
setStageHistory([]);
setEmailInput("");
setEmailError("");
setPollingStatus("idle");
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
}, [planGroup]);
return {
// State
state,
setState,
stageHistory,
setStageHistory,
emailInput,
setEmailInput,
emailError,
setEmailError,
selectedPeriod,
setSelectedPeriod,
installationId,
setInstallationId,
currentLicenseKey,
setCurrentLicenseKey,
licenseKey,
setLicenseKey,
pollingStatus,
setPollingStatus,
// Refs
isMountedRef,
pollingTimeoutRef,
// Computed
selectedPlan,
// Actions
resetState,
};
};
@@ -0,0 +1,52 @@
import { useCallback } from "react";
import {
pollLicenseKeyWithBackoff,
activateLicenseKey,
} from "@app/utils/licenseCheckoutUtils";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* License key polling and activation logic hook
*/
export const useLicensePolling = (
isMountedRef: React.RefObject<boolean>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const pollForLicenseKey = useCallback(
async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error("Failed to activate license key:", activation.error);
}
} else if (result.timedOut) {
console.warn("License key polling timed out");
} else if (result.error) {
console.error("License key polling failed:", result.error);
}
},
[isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated],
);
return { pollForLicenseKey };
};
@@ -0,0 +1,8 @@
export { default as StripeCheckout } from "@app/components/shared/stripeCheckout/StripeCheckout";
export type {
StripeCheckoutProps,
CheckoutStage,
CheckoutState,
PollingStatus,
SavingsCalculation,
} from "@app/components/shared/stripeCheckout/types/checkout";
@@ -0,0 +1,52 @@
import React from "react";
import { Stack, Text, TextInput, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface EmailStageProps {
emailInput: string;
setEmailInput: (email: string) => void;
emailError: string;
onSubmit: () => void;
}
export const EmailStage: React.FC<EmailStageProps> = ({
emailInput,
setEmailInput,
emailError,
onSubmit,
}) => {
const { t } = useTranslation();
return (
<Stack
gap="lg"
style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem 0" }}
>
<Text size="sm" c="dimmed">
{t(
"payment.emailStage.description",
"We'll use this to send your license key and receipts.",
)}
</Text>
<TextInput
label={t("payment.emailStage.emailLabel", "Email Address")}
placeholder={t("payment.emailStage.emailPlaceholder", "[email protected]")}
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
error={emailError}
size="lg"
required
onKeyDown={(e) => {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
<Button size="lg" onClick={onSubmit} disabled={!emailInput.trim()}>
{t("payment.emailStage.continue", "Continue")}
</Button>
</Stack>
);
};
@@ -0,0 +1,23 @@
import React from "react";
import { Alert, Stack, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ErrorStageProps {
error: string;
onClose: () => void;
}
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{error}</Text>
<Button variant="outline" onClick={onClose}>
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,68 @@
import React from "react";
import { Stack, Text, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface PaymentStageProps {
clientSecret: string | null;
selectedPlan: PlanTier | null;
onPaymentComplete: () => void;
}
export const PaymentStage: React.FC<PaymentStageProps> = ({
clientSecret,
selectedPlan,
onPaymentComplete,
}) => {
const { t } = useTranslation();
// Show loading while creating checkout session
if (!clientSecret || !selectedPlan) {
return (
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</Stack>
);
}
if (!stripePromise) {
// This should only happen if embedded mode was attempted without key
// Hosted checkout should have redirected before reaching this component
return (
<Stack align="center" gap="md" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t("payment.redirecting", "Redirecting to secure checkout...")}
</Text>
</Stack>
);
}
return (
<Stack gap="md">
{/* Stripe Embedded Checkout */}
<EmbeddedCheckoutProvider
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret: clientSecret,
onComplete: onPaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Stack>
);
};
@@ -0,0 +1,214 @@
import React from "react";
import {
Stack,
Button,
Text,
Grid,
Paper,
Alert,
Divider,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import {
formatPrice,
calculateMonthlyEquivalent,
calculateTotalWithSeats,
} from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
onSelectPlan: (period: "monthly" | "yearly") => void;
}
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
onSelectPlan,
}) => {
const { t } = useTranslation();
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => onSelectPlan("monthly")}
>
<Stack
gap="md"
style={{ height: "100%" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.monthly", "Monthly")}
</Text>
<Divider />
{isEnterprise && planGroup.monthly.seatPrice ? (
<PriceDisplay
mode="enterprise"
basePrice={planGroup.monthly.price}
seatPrice={planGroup.monthly.seatPrice}
totalPrice={calculateTotalWithSeats(
planGroup.monthly.price,
planGroup.monthly.seatPrice,
seatCount,
)}
currency={planGroup.monthly.currency}
period="month"
seatCount={seatCount}
size="sm"
/>
) : (
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="light" fullWidth size="lg">
{t("payment.planStage.selectMonthly", "Select Monthly")}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
{/* Yearly Option */}
{planGroup.yearly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan("yearly")}
>
{savings && (
<PricingBadge
type="savings"
label={t(
"payment.planStage.savePercent",
"Save {{percent}}%",
{ percent: savings.percent },
)}
/>
)}
<Stack
gap="md"
style={{ height: "100%" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
<Divider />
{isEnterprise && planGroup.yearly.seatPrice ? (
<Stack gap="sm">
<PriceDisplay
mode="enterprise"
basePrice={planGroup.yearly.price}
seatPrice={planGroup.yearly.seatPrice}
totalPrice={calculateMonthlyEquivalent(
calculateTotalWithSeats(
planGroup.yearly.price,
planGroup.yearly.seatPrice,
seatCount,
),
)}
currency={planGroup.yearly.currency}
period="year"
seatCount={seatCount}
size="sm"
/>
<Text size="sm" c="dimmed">
{t(
"payment.planStage.billedYearly",
"Billed yearly at {{currency}}{{amount}}",
{
currency: planGroup.yearly.currency,
amount: calculateTotalWithSeats(
planGroup.yearly.price,
planGroup.yearly.seatPrice,
seatCount,
).toFixed(2),
},
)}
</Text>
</Stack>
) : (
<Stack gap={0}>
<PriceDisplay
mode="simple"
price={calculateMonthlyEquivalent(
planGroup.yearly?.price || 0,
)}
currency={planGroup.yearly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
<Text size="sm" c="dimmed" mt="xs">
{t(
"payment.planStage.billedYearly",
"Billed yearly at {{currency}}{{amount}}",
{
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2),
},
)}
</Text>
</Stack>
)}
{savings && (
<Alert color="green" variant="light" p="sm">
<Text size="sm" fw={600}>
{t(
"payment.planStage.savingsAmount",
"You save {{amount}}",
{
amount: formatPrice(savings.amount, savings.currency),
},
)}
</Text>
</Alert>
)}
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="filled" fullWidth size="lg">
{t("payment.planStage.selectYearly", "Select Yearly")}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
</Grid>
</Stack>
);
};
@@ -0,0 +1,122 @@
import React from "react";
import {
Alert,
Stack,
Text,
Paper,
Code,
Button,
Group,
Loader,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
interface SuccessStageProps {
pollingStatus: PollingStatus;
currentLicenseKey: string | null;
licenseKey: string | null;
onClose: () => void;
}
export const SuccessStage: React.FC<SuccessStageProps> = ({
pollingStatus,
currentLicenseKey,
licenseKey,
onClose,
}) => {
const { t } = useTranslation();
return (
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
<Stack gap="md">
<Text size="sm">
{t(
"payment.successMessage",
"Your subscription has been activated successfully.",
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === "polling" && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t(
"payment.syncingLicense",
"Syncing your upgraded license...",
)
: t(
"payment.generatingLicense",
"Generating your license key...",
)}
</Text>
</Group>
)}
{pollingStatus === "ready" && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t("payment.licenseKey", "Your License Key")}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t("common.copy", "Copy to Clipboard")}
</Button>
<Text size="xs" c="dimmed">
{t(
"payment.licenseInstructions",
"This has been added to your installation. You will receive a copy in your email as well.",
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === "ready" && currentLicenseKey && (
<Alert
color="green"
title={t("payment.upgradeComplete", "Upgrade Complete")}
>
<Text size="sm">
{t(
"payment.upgradeCompleteMessage",
"Your subscription has been upgraded successfully. Your existing license key has been updated.",
)}
</Text>
</Alert>
)}
{pollingStatus === "timeout" && (
<Alert
color="yellow"
title={t("payment.licenseDelayed", "License Key Processing")}
>
<Text size="sm">
{t(
"payment.licenseDelayedMessage",
"Your license key is being generated. Please check your email shortly or contact support.",
)}
</Text>
</Alert>
)}
{pollingStatus === "ready" && (
<Text size="xs" c="dimmed">
{t("payment.canCloseWindow", "You can now close this window.")}
</Text>
)}
<Button onClick={onClose} mt="md">
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,44 @@
import { PlanTierGroup } from "@app/services/licenseService";
export interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
export type CheckoutStage =
| "email"
| "plan-selection"
| "payment"
| "success"
| "error";
export type CheckoutState = {
currentStage: CheckoutStage;
email?: string;
clientSecret?: string;
error?: string;
sessionId?: string;
loading?: boolean;
};
export type PollingStatus = "idle" | "polling" | "ready" | "timeout";
export interface SavingsCalculation {
amount: number;
percent: number;
currency: string;
}
@@ -0,0 +1,48 @@
import { CSSProperties } from "react";
/**
* Shared styling utilities for plan cards
*/
export const CARD_MIN_HEIGHT = "400px";
export const PRICE_FONT_WEIGHT = 600;
/**
* Get card border style based on state
*/
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
return {
borderColor: isHighlighted ? "var(--mantine-color-green-6)" : undefined,
borderWidth: isHighlighted ? "2px" : undefined,
};
}
/**
* Get base card style
*/
export function getBaseCardStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
position: "relative",
display: "flex",
flexDirection: "column",
minHeight: CARD_MIN_HEIGHT,
...getCardBorderStyle(isHighlighted),
};
}
/**
* Get clickable paper style
*/
export function getClickablePaperStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
cursor: "pointer",
transition: "all 0.2s",
height: "100%",
position: "relative",
...getCardBorderStyle(isHighlighted),
};
}
@@ -0,0 +1,52 @@
import { TFunction } from "i18next";
import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Validate email address format
*/
export const validateEmail = (
email: string,
): { valid: boolean; error: string } => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return {
valid: false,
error: "Please enter a valid email address",
};
}
return { valid: true, error: "" };
};
/**
* Get dynamic modal title based on current stage
*/
export const getModalTitle = (
stage: CheckoutStage,
planName: string,
t: TFunction,
): string => {
switch (stage) {
case "email":
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", {
planName,
});
case "plan-selection":
return t(
"payment.planStage.modalTitle",
"Select Billing Period - {{planName}}",
{ planName },
);
case "payment":
return t(
"payment.paymentStage.modalTitle",
"Complete Payment - {{planName}}",
{ planName },
);
case "success":
return t("payment.success", "Payment Successful!");
case "error":
return t("payment.error", "Payment Error");
default:
return t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName });
}
};
@@ -0,0 +1,66 @@
/**
* Shared pricing utilities for plan cards and checkout
*/
export interface PriceCalculation {
displayPrice: number;
displaySeatPrice?: number;
displayCurrency: string;
}
/**
* Calculate monthly equivalent from yearly price
*/
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
return yearlyPrice / 12;
}
/**
* Calculate total price including seats
*/
export function calculateTotalWithSeats(
basePrice: number,
seatPrice: number | undefined,
seatCount: number,
): number {
if (seatPrice === undefined) return basePrice;
return basePrice + seatPrice * seatCount;
}
/**
* Format price with currency symbol
*/
export function formatPrice(
amount: number,
currency: string,
decimals: number = 2,
): string {
return `${currency}${amount.toFixed(decimals)}`;
}
/**
* Calculate display pricing for a plan, showing yearly price divided by 12
* to show the lowest monthly equivalent
*/
export function calculateDisplayPricing(
monthly?: { price: number; seatPrice?: number; currency: string },
yearly?: { price: number; seatPrice?: number; currency: string },
): PriceCalculation {
// Default to monthly if no yearly exists
if (!yearly) {
return {
displayPrice: monthly?.price || 0,
displaySeatPrice: monthly?.seatPrice,
displayCurrency: monthly?.currency || "£",
};
}
// Use yearly price divided by 12 for best value display
return {
displayPrice: calculateMonthlyEquivalent(yearly.price),
displaySeatPrice: yearly.seatPrice
? calculateMonthlyEquivalent(yearly.seatPrice)
: undefined,
displayCurrency: yearly.currency,
};
}
@@ -0,0 +1,44 @@
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Calculate savings for yearly vs monthly plans
* Returns null if both monthly and yearly plans are not available
*/
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number,
): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
let monthlyAnnual: number;
let yearlyTotal: number;
if (
isEnterprise &&
planGroup.monthly.seatPrice &&
planGroup.yearly.seatPrice
) {
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
monthlyAnnual =
(planGroup.monthly.price + planGroup.monthly.seatPrice * seatCount) * 12;
yearlyTotal =
planGroup.yearly.price + planGroup.yearly.seatPrice * seatCount;
} else {
// Server: price * 12 vs yearly price
monthlyAnnual = planGroup.monthly.price * 12;
yearlyTotal = planGroup.yearly.price;
}
const savings = monthlyAnnual - yearlyTotal;
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
return {
amount: savings,
percent: savingsPercent,
currency: planGroup.yearly.currency,
};
};
@@ -0,0 +1,459 @@
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Card,
Text,
Badge,
Group,
Button,
Loader,
Alert,
TextInput,
FileInput,
Select,
} from "@mantine/core";
import { useParticipantSession } from "@app/hooks/workflow/useParticipantSession";
import InfoIcon from "@mui/icons-material/Info";
import DownloadIcon from "@mui/icons-material/Download";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CancelIcon from "@mui/icons-material/Cancel";
interface ParticipantViewProps {
token: string;
}
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const { t } = useTranslation();
const {
session,
participant,
loading,
error,
submitSignature,
decline,
downloadDocument,
} = useParticipantSession(token);
const [certType, setCertType] = useState<string>("P12");
const [password, setPassword] = useState<string>("");
const [certFile, setCertFile] = useState<File | null>(null);
const [location, setLocation] = useState<string>("");
const [reason, setReason] = useState<string>("Document Signing");
const [showSignature, _setShowSignature] = useState<boolean>(true);
const [pageNumber, setPageNumber] = useState<number>(1);
const [declineReason, _setDeclineReason] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [notification, setNotification] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
type CertValidationState =
| { status: "idle" }
| { status: "validating" }
| { status: "valid"; notAfter: string | null; subjectName: string | null }
| { status: "error"; message: string };
const [certValidation, setCertValidation] = useState<CertValidationState>({
status: "idle",
});
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (certType === "SERVER" || !certFile) {
setCertValidation({ status: "idle" });
return;
}
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
setCertValidation({ status: "validating" });
validationTimerRef.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append("participantToken", token);
formData.append("certType", certType);
formData.append("password", password);
if (certType === "JKS") {
formData.append("jksFile", certFile);
} else {
formData.append("p12File", certFile);
}
const res = await fetch(
"/api/v1/workflow/participant/validate-certificate",
{
method: "POST",
body: formData,
},
);
const data = await res.json();
if (data.valid) {
setCertValidation({
status: "valid",
notAfter: data.notAfter,
subjectName: data.subjectName,
});
} else {
setCertValidation({
status: "error",
message:
data.error ??
t(
"certSign.collab.participant.certInvalidFallback",
"Invalid certificate",
),
});
}
} catch {
setCertValidation({
status: "error",
message: t(
"certSign.collab.participant.certNetworkError",
"Could not validate certificate",
),
});
}
}, 600);
return () => {
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
};
}, [certFile, password, certType, token]);
const handleSubmitSignature = async () => {
if (!certFile && certType !== "SERVER") {
setNotification({
type: "error",
message: "Please select a certificate file",
});
return;
}
setIsSubmitting(true);
setNotification(null);
try {
await submitSignature({
participantToken: token,
certType,
password,
p12File: certType === "P12" ? certFile || undefined : undefined,
jksFile: certType === "JKS" ? certFile || undefined : undefined,
showSignature,
pageNumber,
location,
reason,
showLogo: true,
});
setNotification({
type: "success",
message: "Signature submitted successfully!",
});
} catch (err: unknown) {
setNotification({
type: "error",
message: `Failed to submit signature: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
setIsSubmitting(false);
}
};
const handleDecline = async () => {
if (
window.confirm("Are you sure you want to decline signing this document?")
) {
setNotification(null);
try {
await decline(token, declineReason || "Declined by participant");
setNotification({
type: "success",
message: "You have declined this signing request.",
});
} catch (err: unknown) {
setNotification({
type: "error",
message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
};
if (loading && !session) {
return (
<Stack align="center" justify="center" p="xl">
<Loader size="lg" />
<Text c="dimmed">Loading session...</Text>
</Stack>
);
}
if (error) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
{error}
</Alert>
);
}
if (!session || !participant) {
return (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Session not found or access denied.
</Alert>
);
}
const getStatusBadge = (status: string) => {
switch (status) {
case "SIGNED":
return <Badge color="green">Signed</Badge>;
case "DECLINED":
return <Badge color="red">Declined</Badge>;
case "VIEWED":
return <Badge color="blue">Viewed</Badge>;
case "NOTIFIED":
return <Badge color="yellow">Notified</Badge>;
case "PENDING":
return <Badge color="gray">Pending</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
const canSign =
!participant.hasCompleted &&
!participant.isExpired &&
session.status === "IN_PROGRESS";
return (
<Stack gap="md">
{notification && (
<Alert
icon={
notification.type === "success" ? (
<CheckCircleIcon fontSize="small" />
) : (
<InfoIcon fontSize="small" />
)
}
color={notification.type === "success" ? "green" : "red"}
withCloseButton
onClose={() => setNotification(null)}
>
{notification.message}
</Alert>
)}
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<div>
<Text fw={500} size="lg">
{session.documentName}
</Text>
<Text size="sm" c="dimmed">
From: {session.ownerUsername}
</Text>
</div>
{getStatusBadge(participant.status)}
</Group>
{session.message && (
<Alert
icon={<InfoIcon fontSize="small" />}
color="blue"
variant="light"
>
<Text size="sm">{session.message}</Text>
</Alert>
)}
{session.dueDate && (
<Text size="sm" c="dimmed">
Due Date: {session.dueDate}
</Text>
)}
<Group gap="xs" mt="sm">
<Button
size="sm"
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => downloadDocument(token)}
variant="light"
>
Download Document
</Button>
</Group>
</Stack>
</Card>
{canSign && (
<Card shadow="sm" padding="md" radius="md" withBorder>
<Stack gap="md">
<Text fw={500} size="lg">
Sign Document
</Text>
<Select
label="Certificate Type"
value={certType}
onChange={(value) => setCertType(value || "P12")}
data={[
{ value: "P12", label: "P12/PKCS12 Certificate" },
{ value: "JKS", label: "JKS Keystore" },
{ value: "SERVER", label: "Server Certificate (if available)" },
]}
size="sm"
data-testid="cert-type-select"
/>
{certType !== "SERVER" && (
<>
<FileInput
label="Certificate File"
placeholder="Select certificate file"
value={certFile}
onChange={setCertFile}
accept=".p12,.pfx,.jks"
size="sm"
data-testid="cert-file-input"
/>
<TextInput
label="Certificate Password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
size="sm"
data-testid="cert-password-input"
/>
{/* Certificate validation feedback */}
{certValidation.status === "validating" && (
<Text
size="sm"
c="dimmed"
data-testid="cert-validation-feedback"
>
{t(
"certSign.collab.participant.certValidating",
"Validating certificate...",
)}
</Text>
)}
{certValidation.status === "valid" && (
<Text
size="sm"
c="green"
data-testid="cert-validation-feedback"
>
{t(
"certSign.collab.participant.certValid",
"✓ Certificate valid",
)}
{certValidation.notAfter
? t(
"certSign.collab.participant.certValidUntil",
" until {{date}}",
{
date: new Date(
certValidation.notAfter,
).toLocaleDateString(),
},
)
: ""}
{certValidation.subjectName
? ` · ${certValidation.subjectName}`
: ""}
</Text>
)}
{certValidation.status === "error" && (
<Text
size="sm"
c="red"
data-testid="cert-validation-feedback"
>
{t(
"certSign.collab.participant.certInvalid",
"✗ {{error}}",
{ error: certValidation.message },
)}
</Text>
)}
</>
)}
<TextInput
label="Location"
placeholder="e.g., San Francisco, CA"
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Reason"
placeholder="e.g., Document approval"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
size="sm"
/>
<TextInput
label="Page Number (optional)"
type="number"
value={pageNumber}
onChange={(e) =>
setPageNumber(parseInt(e.currentTarget.value) || 1)
}
size="sm"
min={1}
/>
<Group gap="xs">
<Button
leftSection={<CheckCircleIcon fontSize="small" />}
onClick={handleSubmitSignature}
loading={isSubmitting}
disabled={
isSubmitting || certValidation.status === "validating"
}
color="green"
data-testid="submit-signature-button"
>
Submit Signature
</Button>
<Button
leftSection={<CancelIcon fontSize="small" />}
onClick={handleDecline}
color="red"
variant="light"
data-testid="decline-button"
>
Decline
</Button>
</Group>
</Stack>
</Card>
)}
{participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
this document.
</Alert>
)}
{participant.isExpired && (
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
Your access to this document has expired.
</Alert>
)}
</Stack>
);
};
export default ParticipantView;
@@ -0,0 +1,100 @@
import type { PlanFeature } from "@app/types/license";
/**
* Shared plan feature definitions for Stirling PDF Self-Hosted
* Used by both dynamic (Stripe) and static (fallback) plan displays
*/
export const PLAN_FEATURES = {
FREE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: true },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: false },
{ name: "External Database", included: false },
{ name: "Editing text in pdfs", included: false },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: false },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
SERVER: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "Up to 5 users", included: false },
{ name: "Unlimited users", included: true },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: true },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
ENTERPRISE: [
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: false },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: true },
{ name: "SSO", included: true },
{ name: "SAML", included: true },
{ name: "Auditing", included: true },
{ name: "Usage tracking", included: true },
{ name: "Prometheus Support", included: true },
{ name: "Custom PDF metadata", included: true },
] as PlanFeature[],
} as const;
export const PLAN_HIGHLIGHTS = {
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
SERVER_MONTHLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Cancel anytime",
],
SERVER_YEARLY: [
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Save with annual billing",
],
ENTERPRISE_MONTHLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Per-seat licensing",
],
ENTERPRISE_YEARLY: [
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Save with annual billing",
],
} as const;
@@ -0,0 +1,58 @@
/**
* Static Stripe payment links for offline/self-hosted environments
*
* These links are used when Supabase is not configured, allowing users to
* purchase licenses directly through Stripe hosted checkout pages.
*
*/
export interface StaticStripeLinks {
server: {
monthly: string;
yearly: string;
};
enterprise: {
monthly: string;
yearly: string;
};
billingPortal: string;
}
// PRODCUTION LINKS FOR LIVE SERVER
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
server: {
monthly: "https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03",
yearly: "https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02",
},
enterprise: {
monthly: "",
yearly: "",
},
billingPortal: "https://billing.stripe.com/p/login/5kA6pT6Xa7z59HO4gg",
};
// LINKS FOR TEST SERVER:
// export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
// server: {
// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02',
// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03',
// },
// enterprise: {
// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04',
// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05',
// },
// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
// };
/**
* Builds a Stripe URL with a prefilled email parameter
* @param baseUrl - The base Stripe checkout URL
* @param email - The email address to prefill
* @returns The complete URL with encoded email parameter
*/
export function buildStripeUrlWithEmail(
baseUrl: string,
email: string,
): string {
const encodedEmail = encodeURIComponent(email);
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
}
@@ -0,0 +1,447 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import licenseService, {
PlanTierGroup,
LicenseInfo,
mapLicenseToTier,
PlanTier,
} from "@app/services/licenseService";
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
import { userManagementService } from "@app/services/userManagementService";
import { alert } from "@app/components/toast";
import {
pollLicenseKeyWithBackoff,
activateLicenseKey,
resyncExistingLicense,
} from "@app/utils/licenseCheckoutUtils";
import { useLicense } from "@app/contexts/LicenseContext";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
import { getPreferredCurrency } from "@app/utils/currencyDetection";
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (auto-detected from locale)
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
interface CheckoutContextValue {
openCheckout: (
tier: "server" | "enterprise",
options?: CheckoutOptions,
) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | undefined>(
undefined,
);
interface CheckoutProviderProps {
children: ReactNode;
defaultCurrency?: string;
}
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency,
}) => {
const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] =
useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(() => {
// Use provided default or auto-detect from locale
return defaultCurrency || getPreferredCurrency(i18n.language);
});
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
isUpgrade: boolean;
licenseKey?: string;
} | null>(null);
// Lazy-loaded plans state (no fetch on mount)
const [plans, setPlans] = useState<PlanTier[]>([]);
const [plansLoaded, setPlansLoaded] = useState(false);
const [plansLoading, setPlansLoading] = useState(false);
// Lazy fetch plans only when needed
const fetchPlansIfNeeded = useCallback(
async (currency: string) => {
// Don't fetch if already loading
if (plansLoading) return;
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
console.error("Failed to fetch plans:", error);
// Don't block - let components handle the error
} finally {
setPlansLoading(false);
}
},
[plansLoading],
);
const refetchPlans = useCallback(() => {
setPlansLoaded(false); // Force refetch
return fetchPlansIfNeeded(currentCurrency);
}, [currentCurrency, fetchPlansIfNeeded]);
// Handle return from hosted Stripe checkout
useEffect(() => {
const handleCheckoutReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const paymentStatus = urlParams.get("payment_status");
const sessionId = urlParams.get("session_id");
if (paymentStatus === "success" && sessionId) {
console.log("Payment successful via hosted checkout:", sessionId);
// Clear URL parameters
window.history.replaceState({}, "", window.location.pathname);
// Fetch current license info to determine upgrade vs new
let licenseInfo: LicenseInfo | null = null;
try {
licenseInfo = await licenseService.getLicenseInfo();
} catch (err) {
console.warn("Could not fetch license info:", err);
}
// Check if this is an upgrade or new subscription
// Only treat as upgrade if there's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// UPGRADE: Resync existing license with Keygen
console.log("Upgrade detected - resyncing existing license");
const activation = await resyncExistingLicense();
if (activation.success) {
console.log(
"License synced successfully, refreshing license context",
);
// Ensure plans are loaded before using them
if (!plansLoaded) {
await fetchPlansIfNeeded(currentCurrency);
}
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier =
activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({ isUpgrade: true });
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: "success",
title: t("payment.upgradeSuccess"),
});
}
} else {
console.error(
"Failed to sync license after upgrade:",
activation.error,
);
alert({
alertType: "error",
title: t("payment.syncError"),
});
}
} else {
// NEW SUBSCRIPTION: Poll for license key
console.log("New subscription - polling for license key");
try {
const installationId = await licenseService.getInstallationId();
console.log(
"Polling for license key with installation ID:",
installationId,
);
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installationId);
if (result.success && result.licenseKey) {
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey);
if (activation.success) {
console.log(`License key activated: ${activation.licenseType}`);
// Ensure plans are loaded before using them
if (!plansLoaded) {
await fetchPlansIfNeeded(currentCurrency);
}
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier =
activation.licenseType === "ENTERPRISE"
? "enterprise"
: "server";
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success with license key
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({
isUpgrade: false,
licenseKey: result.licenseKey,
});
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: "success",
title: t("payment.licenseActivated"),
});
}
} else {
console.error("Failed to save license key:", activation.error);
alert({
alertType: "error",
title: t("payment.licenseSaveError"),
});
}
} else if (result.timedOut) {
console.warn("License key polling timed out");
alert({
alertType: "warning",
title: t("payment.licenseDelayed"),
});
} else {
console.error("License key polling failed:", result.error);
alert({
alertType: "error",
title: t("payment.licensePollingError"),
});
}
} catch (error) {
console.error("Failed to poll for license key:", error);
alert({
alertType: "error",
title: t("payment.licenseRetrievalError"),
});
}
}
} else if (paymentStatus === "canceled") {
console.log("Payment canceled by user");
// Clear URL parameters
window.history.replaceState({}, "", window.location.pathname);
alert({
alertType: "warning",
title: t("payment.paymentCanceled"),
});
}
};
handleCheckoutReturn();
}, [
t,
refetchPlans,
refetchLicense,
plans,
fetchPlansIfNeeded,
plansLoaded,
currentCurrency,
]);
const openCheckout = useCallback(
async (tier: "server" | "enterprise", options: CheckoutOptions = {}) => {
try {
setIsLoading(true);
// Check if Supabase is configured
if (!isSupabaseConfigured) {
throw new Error(
"Checkout is not available. Supabase is not configured.",
);
}
// Update currency if provided
const currency = options.currency || currentCurrency;
if (currency !== currentCurrency) {
setCurrentCurrency(currency);
}
// Fetch plans if not already loaded
if (!plansLoaded) {
await fetchPlansIfNeeded(currency);
}
// Fetch license info and user data for seat calculations
let licenseInfo: LicenseInfo | null = null;
let totalUsers = 0;
try {
const [licenseData, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers(),
]);
licenseInfo = licenseData;
totalUsers = userData.totalUsers || 0;
} catch (err) {
console.warn(
"Could not fetch license/user info, proceeding with defaults:",
err,
);
}
// Calculate minimum seats for enterprise upgrades
let calculatedMinSeats = options.minimumSeats || 1;
if (tier === "enterprise" && !options.minimumSeats) {
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === "server" || currentTier === "free") {
// Upgrading from Server (unlimited) to Enterprise (per-seat)
// Use current total user count as minimum
calculatedMinSeats = Math.max(totalUsers, 1);
console.log(
`Setting minimum seats from server user count: ${calculatedMinSeats}`,
);
} else if (currentTier === "enterprise") {
// Upgrading within Enterprise (e.g., monthly to yearly)
// Use current licensed seat count as minimum
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
console.log(
`Setting minimum seats from current license: ${calculatedMinSeats}`,
);
}
}
// Find the plan group for the requested tier
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (!planGroup) {
throw new Error(`No ${tier} plan available`);
}
// Store options for callbacks
setCurrentOptions(options);
setMinimumSeats(calculatedMinSeats);
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to open checkout";
console.error("Error opening checkout:", errorMessage);
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded],
);
const closeCheckout = useCallback(() => {
setIsOpen(false);
setSelectedPlanGroup(null);
setCurrentOptions({});
setHostedCheckoutSuccess(null);
// Refetch plans and license after modal closes to update subscription display
refetchPlans();
refetchLicense();
}, [refetchPlans, refetchLicense]);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log("Payment successful, session:", sessionId);
currentOptions.onSuccess?.(sessionId);
// Don't close modal - let user view license key and close manually
},
[currentOptions],
);
const handlePaymentError = useCallback(
(error: string) => {
console.error("Payment error:", error);
currentOptions.onError?.(error);
},
[currentOptions],
);
const handleLicenseActivated = useCallback(
(licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log("License activated:", licenseInfo);
// Could expose this via context if needed
},
[],
);
const contextValue: CheckoutContextValue = {
openCheckout,
closeCheckout,
isOpen,
isLoading,
};
return (
<CheckoutContext.Provider value={contextValue}>
{children}
{/* Global Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
)}
</CheckoutContext.Provider>
);
};
export const useCheckout = (): CheckoutContextValue => {
const context = useContext(CheckoutContext);
if (!context) {
throw new Error("useCheckout must be used within CheckoutProvider");
}
return context;
};
@@ -0,0 +1,150 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useMemo,
useRef,
ReactNode,
} from "react";
import { useLocation } from "react-router-dom";
import licenseService, { LicenseInfo } from "@app/services/licenseService";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { getSimulatedLicenseInfo } from "@app/testing/serverExperienceSimulations";
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
loading: boolean;
error: string | null;
refetchLicense: () => Promise<void>;
}
const LicenseContext = createContext<LicenseContextValue | undefined>(
undefined,
);
interface LicenseProviderProps {
children: ReactNode;
}
export const LicenseProvider: React.FC<LicenseProviderProps> = ({
children,
}) => {
const { config } = useAppConfig();
const location = useLocation();
const configRef = useRef(config);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Keep ref updated with latest config
useEffect(() => {
configRef.current = config;
}, [config]);
const refetchLicense = useCallback(async () => {
// Wait for config to load if it's not available yet
let currentConfig = configRef.current;
if (!currentConfig) {
console.log("[LicenseContext] Config not loaded yet, waiting...");
// OPTIMIZATION: Reduced from 5s to 1s - config should load quickly
const maxWait = 1000;
const startTime = Date.now();
while (!configRef.current && Date.now() - startTime < maxWait) {
await new Promise((resolve) => setTimeout(resolve, 100));
currentConfig = configRef.current;
}
if (!currentConfig) {
console.error("[LicenseContext] Config failed to load after waiting");
setLoading(false);
return;
}
}
// Only fetch license info if user is an admin
if (!currentConfig.isAdmin) {
console.log(
"[LicenseContext] User is not an admin, skipping license fetch",
);
setLoading(false);
return;
}
console.log("[LicenseContext] Fetching license info");
try {
const testInfo = getSimulatedLicenseInfo();
if (testInfo) {
setLicenseInfo(testInfo);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
setLicenseInfo(info);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch license info";
console.error("Error fetching license info:", errorMessage);
setError(errorMessage);
setLicenseInfo(null);
} finally {
setLoading(false);
}
}, []);
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
// CRITICAL FIX: Skip license fetch on auth routes to prevent race conditions
// during SAML/OAuth callback processing. License isn't needed until user
// is authenticated and navigates to main app.
const isAuthRoute =
location.pathname === "/login" ||
location.pathname === "/signup" ||
location.pathname === "/auth/callback" ||
location.pathname.startsWith("/invite/");
if (isAuthRoute) {
console.log("[LicenseContext] On auth route, skipping license fetch");
setLoading(false);
return;
}
if (config) {
refetchLicense();
}
}, [config, refetchLicense, location.pathname]);
const contextValue: LicenseContextValue = useMemo(
() => ({
licenseInfo,
loading,
error,
refetchLicense,
}),
[licenseInfo, loading, error, refetchLicense],
);
return (
<LicenseContext.Provider value={contextValue}>
{children}
</LicenseContext.Provider>
);
};
export const useOptionalLicense = (): LicenseContextValue | undefined => {
return useContext(LicenseContext);
};
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
throw new Error("useLicense must be used within LicenseProvider");
}
return context;
};
@@ -0,0 +1,381 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import apiClient from "@app/services/apiClient";
import { isAxiosError } from "axios";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { useLicense } from "@app/contexts/LicenseContext";
import {
getSimulatedAdminUsage,
getSimulatedWauResponse,
} from "@app/testing/serverExperienceSimulations";
const SELF_REPORTED_ADMIN_KEY = "stirling-self-reported-admin";
const FREE_TIER_LIMIT = 5;
type UserCountSource = "admin" | "estimate" | "unknown";
interface WeeklyActiveUsersResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface UserCountState {
totalUsers: number | null;
weeklyActiveUsers: number | null;
loading: boolean;
source: UserCountSource;
lastUpdated: number | null;
error: string | null;
}
export type ServerScenarioKey =
| "unknown"
| "licensed"
| "no-login-user-under-limit-no-license"
| "no-login-admin-under-limit-no-license"
| "no-login-user-over-limit-no-license"
| "no-login-admin-over-limit-no-license"
| "login-user-under-limit-no-license"
| "login-admin-under-limit-no-license"
| "login-user-over-limit-no-license"
| "login-admin-over-limit-no-license";
export interface ServerExperienceValue {
loginEnabled: boolean;
configIsAdmin: boolean;
effectiveIsAdmin: boolean;
selfReportedAdmin: boolean;
isAuthenticated: boolean;
isNewServer: boolean | null;
isNewUser: boolean | null;
premiumEnabled: boolean | null;
license: string | undefined;
runningProOrHigher: boolean | undefined;
runningEE: boolean | undefined;
hasPaidLicense: boolean;
licenseKeyValid: boolean | null;
licenseLoading: boolean;
licenseInfoAvailable: boolean;
totalUsers: number | null;
weeklyActiveUsers: number | null;
userCountLoading: boolean;
userCountError: string | null;
userCountSource: UserCountSource;
userCountResolved: boolean;
overFreeTierLimit: boolean | null;
freeTierLimit: number;
refreshUserCounts: () => Promise<void>;
setSelfReportedAdmin: (value: boolean) => void;
scenarioKey: ServerScenarioKey;
}
const ServerExperienceContext = createContext<
ServerExperienceValue | undefined
>(undefined);
function getStoredSelfReportedAdmin(): boolean {
if (typeof window === "undefined") {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === "true";
} catch {
return false;
}
}
function getErrorMessage(error: unknown): string {
if (typeof error === "string") {
return error;
}
if (
isAxiosError(error) &&
typeof error.response?.data?.message === "string"
) {
return error.response.data.message;
}
if (error instanceof Error) {
return error.message;
}
return "Unable to load server usage";
}
export function ServerExperienceProvider({
children,
}: {
children: ReactNode;
}) {
const { config } = useAppConfig();
const { user } = useAuth();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(
getStoredSelfReportedAdmin,
);
const [userCountState, setUserCountState] = useState<UserCountState>({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: "unknown",
lastUpdated: null,
error: null,
});
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin =
configIsAdmin || (!loginEnabled && selfReportedAdmin);
const isAuthenticated = Boolean(user);
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);
if (typeof window === "undefined") {
return;
}
try {
if (value) {
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, "true");
} else {
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
}
} catch {
// ignore storage failures
}
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key === SELF_REPORTED_ADMIN_KEY) {
setSelfReportedAdminState(event.newValue === "true");
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
useEffect(() => {
if (!config) {
return;
}
if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
setSelfReportedAdmin(true);
}
}, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
const fetchUserCounts = useCallback(async () => {
if (!config) {
return;
}
if (!config.appVersion) {
return;
}
if (loginEnabled && !isAuthenticated) {
return;
}
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
// Use WAU estimate for no-login scenarios OR for login non-admin users
const shouldUseEstimate = config.enableLogin === false || !config.isAdmin;
setUserCountState((prev) => ({
...prev,
loading: true,
error: null,
}));
try {
if (shouldUseAdminData) {
const testResponse = getSimulatedAdminUsage();
const responseData =
testResponse ??
(
await apiClient.get<{ totalUsers?: number }>(
"/api/v1/proprietary/ui-data/admin-settings",
{
suppressErrorToast: true,
},
)
).data;
const totalUsers =
typeof responseData?.totalUsers === "number"
? responseData.totalUsers
: null;
setUserCountState({
totalUsers,
weeklyActiveUsers: null,
loading: false,
source: "admin",
lastUpdated: Date.now(),
error: null,
});
return;
}
if (shouldUseEstimate) {
const testResponse = getSimulatedWauResponse();
const responseData =
testResponse ??
(
await apiClient.get<WeeklyActiveUsersResponse>("/api/v1/info/wau", {
suppressErrorToast: true,
})
).data;
const weeklyActiveUsers =
typeof responseData?.weeklyActiveUsers === "number"
? responseData.weeklyActiveUsers
: null;
setUserCountState({
totalUsers: weeklyActiveUsers,
weeklyActiveUsers,
loading: false,
source: "estimate",
lastUpdated: Date.now(),
error: null,
});
}
} catch (error) {
setUserCountState({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: "unknown",
lastUpdated: null,
error: getErrorMessage(error),
});
}
}, [config]);
useEffect(() => {
void fetchUserCounts();
}, [fetchUserCounts]);
const refreshUserCounts = useCallback(async () => {
await fetchUserCounts();
}, [fetchUserCounts]);
const hasPaidLicense = useMemo(() => {
return (
config?.license === "SERVER" ||
config?.license === "PRO" ||
config?.license === "ENTERPRISE"
);
}, [config?.license]);
const licenseKeyValid = useMemo(() => {
if (licenseInfo) {
return licenseInfo.hasKey && licenseInfo.enabled;
}
if (config?.premiumEnabled) {
return true;
}
return null;
}, [config?.premiumEnabled, licenseInfo]);
const overFreeTierLimit = useMemo(() => {
if (typeof userCountState.totalUsers !== "number") {
return null;
}
return userCountState.totalUsers > FREE_TIER_LIMIT;
}, [userCountState.totalUsers]);
const userCountResolved =
!userCountState.loading &&
userCountState.source !== "unknown" &&
userCountState.totalUsers !== null;
const scenarioKey = useMemo<ServerScenarioKey>(() => {
if (hasPaidLicense) {
return "licensed";
}
if (!userCountResolved || typeof userCountState.totalUsers !== "number") {
return "unknown";
}
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
if (!loginEnabled) {
if (selfReportedAdmin) {
return overLimit
? "no-login-admin-over-limit-no-license"
: "no-login-admin-under-limit-no-license";
}
return overLimit
? "no-login-user-over-limit-no-license"
: "no-login-user-under-limit-no-license";
}
if (configIsAdmin) {
return overLimit
? "login-admin-over-limit-no-license"
: "login-admin-under-limit-no-license";
}
return overLimit
? "login-user-over-limit-no-license"
: "login-user-under-limit-no-license";
}, [
hasPaidLicense,
userCountResolved,
userCountState.totalUsers,
loginEnabled,
selfReportedAdmin,
configIsAdmin,
]);
const value: ServerExperienceValue = {
loginEnabled,
configIsAdmin,
effectiveIsAdmin,
selfReportedAdmin,
isAuthenticated,
isNewServer: config?.isNewServer ?? null,
isNewUser: config?.isNewUser ?? null,
premiumEnabled: config?.premiumEnabled ?? null,
license: config?.license,
runningProOrHigher: config?.runningProOrHigher,
runningEE: config?.runningEE,
hasPaidLicense,
licenseKeyValid,
licenseLoading,
licenseInfoAvailable: Boolean(licenseInfo),
totalUsers: userCountState.totalUsers,
weeklyActiveUsers: userCountState.weeklyActiveUsers,
userCountLoading: userCountState.loading,
userCountError: userCountState.error,
userCountSource: userCountState.source,
userCountResolved,
overFreeTierLimit,
freeTierLimit: FREE_TIER_LIMIT,
refreshUserCounts,
setSelfReportedAdmin,
scenarioKey,
};
return (
<ServerExperienceContext.Provider value={value}>
{children}
</ServerExperienceContext.Provider>
);
}
export function useServerExperienceContext() {
const context = useContext(ServerExperienceContext);
if (!context) {
throw new Error(
"useServerExperience must be used within ServerExperienceProvider",
);
}
return context;
}
@@ -0,0 +1,269 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
ReactNode,
} from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import UpdateSeatsModal from "@app/components/shared/UpdateSeatsModal";
import { userManagementService } from "@app/services/userManagementService";
import { alert } from "@app/components/toast";
import { useOptionalLicense } from "@app/contexts/LicenseContext";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
export interface UpdateSeatsOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
}
interface UpdateSeatsContextValue {
openUpdateSeats: (options?: UpdateSeatsOptions) => Promise<void>;
closeUpdateSeats: () => void;
isOpen: boolean;
isLoading: boolean;
}
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(
undefined,
);
interface UpdateSeatsProviderProps {
children: ReactNode;
}
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({
children,
}) => {
const { t } = useTranslation();
const location = useLocation();
// Use optional hook - won't throw during setup wizard when license provider isn't needed
const license = useOptionalLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentSeats, setCurrentSeats] = useState<number>(1);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentOptions, setCurrentOptions] = useState<UpdateSeatsOptions>({});
// Handle return from Stripe billing portal
useEffect(() => {
// CRITICAL FIX: Don't run billing check on auth routes to prevent race conditions
// during SAML/OAuth callback. This check only matters after successful billing
// portal redirects, which never happen on auth routes.
const isAuthRoute =
location.pathname === "/login" ||
location.pathname === "/signup" ||
location.pathname === "/auth/callback" ||
location.pathname.startsWith("/invite/");
if (isAuthRoute) {
console.log(
"[UpdateSeatsContext] On auth route, skipping billing return check",
);
return;
}
const handleBillingReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const seatsUpdated = urlParams.get("seats_updated");
if (seatsUpdated === "true") {
console.log("Seats updated successfully, syncing license with Keygen");
// Clear URL parameters
window.history.replaceState({}, "", window.location.pathname);
try {
// Wait a moment for Stripe webhook to process
await new Promise((resolve) => setTimeout(resolve, 2000));
// Resync license with Keygen (not just local fetch)
console.log("Seat update detected - resyncing license with Keygen");
const activation = await resyncExistingLicense();
if (activation.success) {
console.log("License synced successfully after seat update");
// Refresh global license context
await license?.refetchLicense();
// Get updated license info for notification
const updatedLicense = await licenseService.getLicenseInfo();
alert({
alertType: "success",
title: t("billing.seatsUpdated", "Seats Updated"),
body: t(
"billing.seatsUpdatedMessage",
"Your enterprise seats have been updated to {{seats}}",
{
seats: updatedLicense.maxUsers,
},
),
});
} else {
throw new Error(activation.error || "Failed to sync license");
}
} catch (error) {
console.error("Failed to sync license after seat update:", error);
alert({
alertType: "warning",
title: t("billing.updateProcessing", "Update Processing"),
body: t(
"billing.updateProcessingMessage",
"Your seat update is being processed. Please refresh in a few moments.",
),
});
}
}
};
// CRITICAL FIX: Properly handle async function and catch errors
handleBillingReturn().catch((error) => {
console.error(
"[UpdateSeatsContext] Error in billing return handler:",
error,
);
// Don't throw - this is initialization, should not block rendering
});
}, [t, location.pathname, license]);
const openUpdateSeats = useCallback(
async (options: UpdateSeatsOptions = {}) => {
try {
setIsLoading(true);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers(),
]);
// Validate this is an enterprise license
if (!licenseInfo || licenseInfo.licenseType !== "ENTERPRISE") {
throw new Error(
t(
"billing.notEnterprise",
"Seat management is only available for enterprise licenses",
),
);
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
const currentUserCount = userData.totalUsers || 0;
// Minimum seats must be at least the current number of users
const calculatedMinSeats = Math.max(currentUserCount, 1);
console.log(
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`,
);
setCurrentSeats(currentLicenseSeats);
setMinimumSeats(calculatedMinSeats);
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to open seat update";
console.error("Error opening seat update:", errorMessage);
alert({
alertType: "error",
title: t("common.error", "Error"),
body: errorMessage,
});
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[t],
);
const closeUpdateSeats = useCallback(() => {
setIsOpen(false);
setCurrentOptions({});
// Refetch license after modal closes to update UI
license?.refetchLicense();
}, [license]);
const handleUpdateSeats = useCallback(
async (newSeatCount: number): Promise<string> => {
try {
// Get current license key
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo?.licenseKey) {
throw new Error("No license key found");
}
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
// Call manage-billing function with new seat count
const portalUrl = await licenseService.updateEnterpriseSeats(
newSeatCount,
licenseInfo.licenseKey,
);
return portalUrl;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to update seats";
console.error("Error updating seats:", errorMessage);
currentOptions.onError?.(errorMessage);
throw err;
}
},
[currentSeats, currentOptions],
);
const handleSuccess = useCallback(() => {
console.log("Seat update initiated successfully");
currentOptions.onSuccess?.();
}, [currentOptions]);
const handleError = useCallback(
(error: string) => {
console.error("Seat update error:", error);
currentOptions.onError?.(error);
},
[currentOptions],
);
return (
<UpdateSeatsContext.Provider
value={{
openUpdateSeats,
closeUpdateSeats,
isOpen,
isLoading,
}}
>
{children}
<UpdateSeatsModal
opened={isOpen}
onClose={closeUpdateSeats}
currentSeats={currentSeats}
minimumSeats={minimumSeats}
_onSuccess={handleSuccess}
onError={handleError}
onUpdateSeats={handleUpdateSeats}
/>
</UpdateSeatsContext.Provider>
);
};
export const useUpdateSeats = (): UpdateSeatsContextValue => {
const context = useContext(UpdateSeatsContext);
if (!context) {
throw new Error(
"useUpdateSeats must be used within an UpdateSeatsProvider",
);
}
return context;
};
export default UpdateSeatsContext;
@@ -0,0 +1,12 @@
import { useMemo } from "react";
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
/**
* Hook that provides the proprietary tool registry.
*
* This is the definition of all proprietary tools,
* and will be included in the main tool registry.
*/
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
}
@@ -0,0 +1,29 @@
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Default (web/proprietary) logout handler: sign out and redirect to /login.
* Desktop builds override this file via path resolution.
*/
export function useAccountLogout() {
return async ({
signOut,
redirectToLogin,
}: AccountLogoutDeps): Promise<void> => {
try {
if (typeof window !== "undefined") {
window.sessionStorage.setItem(
"stirling_sso_auto_login_logged_out",
"1",
);
}
await signOut();
} finally {
redirectToLogin();
}
};
}
@@ -0,0 +1,7 @@
/**
* Extension hook for platform-specific OAuth callback handling.
* Proprietary/web builds are no-op.
*/
export async function handleAuthCallbackSuccess(_token: string): Promise<void> {
// no-op for web builds
}
@@ -0,0 +1,11 @@
/**
* Extension hooks for platform-specific auth cleanup.
* Proprietary/web builds are no-op.
*/
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
// no-op for web builds
}
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
// no-op for web builds
}
@@ -0,0 +1,9 @@
/**
* Extension hook for platform-specific OAuth navigation.
* Proprietary/web builds default to in-window navigation.
*/
export async function startOAuthNavigation(
_redirectUrl: string,
): Promise<boolean> {
return false;
}
@@ -0,0 +1,33 @@
export interface PlatformSessionUser {
username: string;
email?: string;
}
/**
* Proprietary/web default: no desktop SaaS auth bridge.
*/
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
return false;
}
/**
* Proprietary/web default: no platform user store.
*/
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
return null;
}
/**
* Proprietary/web default: no platform refresh path.
*/
export async function refreshPlatformSession(): Promise<boolean> {
return false;
}
/**
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
*/
export async function savePlatformToken(_token: string): Promise<void> {
// Web mode: token already saved to localStorage in springAuthClient
// No additional platform storage needed
}
@@ -0,0 +1,44 @@
import { useState, useEffect } from "react";
import licenseService, {
PlanTier,
PlansResponse,
} from "@app/services/licenseService";
export interface UsePlansReturn {
plans: PlanTier[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
} catch (err) {
console.error("Error fetching plans:", err);
setError(err instanceof Error ? err.message : "Failed to fetch plans");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
return {
plans,
loading,
error,
refetch: fetchPlans,
};
};
@@ -0,0 +1,4 @@
export function useRequestHeaders(): HeadersInit {
const token = localStorage.getItem("stirling_jwt");
return token ? { Authorization: `Bearer ${token}` } : {};
}
@@ -0,0 +1,5 @@
import { useServerExperienceContext } from "@app/contexts/ServerExperienceContext";
export function useServerExperience() {
return useServerExperienceContext();
}
@@ -0,0 +1,19 @@
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAuth } from "@app/auth/UseSession";
import { useIsMobile } from "@app/hooks/useIsMobile";
export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const { session, loading } = useAuth();
const isMobile = useIsMobile();
// Only show welcome modal if user is authenticated (session exists)
// This prevents the modal from showing on login screens when security is enabled
return (
!loading &&
!preferences.hasCompletedOnboarding &&
preferences.toolPanelModePromptSeen &&
!isMobile &&
!!session
);
}
@@ -0,0 +1,150 @@
import { useState, useCallback, useEffect } from "react";
import { isAxiosError } from "axios";
import workflowService, {
WorkflowSessionResponse,
ParticipantResponse,
SignatureSubmissionRequest,
} from "@app/services/workflowService";
export interface UseParticipantSessionResult {
session: WorkflowSessionResponse | null;
participant: ParticipantResponse | null;
loading: boolean;
error: string | null;
loadSession: (token: string) => Promise<void>;
submitSignature: (request: SignatureSubmissionRequest) => Promise<void>;
decline: (token: string, reason?: string) => Promise<void>;
downloadDocument: (token: string) => Promise<void>;
}
/**
* Hook for managing workflow session from participant perspective
*/
export const useParticipantSession = (
token?: string,
): UseParticipantSessionResult => {
const [session, setSession] = useState<WorkflowSessionResponse | null>(null);
const [participant, setParticipant] = useState<ParticipantResponse | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSession = useCallback(async (token: string) => {
setLoading(true);
setError(null);
try {
const [sessionData, participantData] = await Promise.all([
workflowService.getSessionByToken(token),
workflowService.getParticipantDetails(token),
]);
setSession(sessionData);
setParticipant(participantData);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) ||
"Failed to load session";
setError(errorMsg);
} finally {
setLoading(false);
}
}, []);
const submitSignature = useCallback(
async (request: SignatureSubmissionRequest) => {
setLoading(true);
setError(null);
try {
const updatedParticipant =
await workflowService.submitSignature(request);
setParticipant(updatedParticipant);
// Reload session to get updated status
if (request.participantToken) {
await loadSession(request.participantToken);
}
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) ||
"Failed to submit signature";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession],
);
const decline = useCallback(
async (token: string, reason?: string) => {
setLoading(true);
setError(null);
try {
const updatedParticipant = await workflowService.declineParticipation(
token,
reason,
);
setParticipant(updatedParticipant);
// Reload session
await loadSession(token);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) ||
"Failed to decline";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession],
);
const downloadDocument = useCallback(
async (token: string) => {
setLoading(true);
setError(null);
try {
const pdfBlob = await workflowService.getParticipantDocument(token);
const url = window.URL.createObjectURL(pdfBlob);
const a = document.createElement("a");
a.href = url;
a.download = session?.documentName || "document.pdf";
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) ||
"Failed to download document";
setError(errorMsg);
} finally {
setLoading(false);
}
},
[session],
);
// Auto-load session if token is provided
useEffect(() => {
if (token) {
loadSession(token);
}
}, [token, loadSession]);
return {
session,
participant,
loading,
error,
loadSession,
submitSignature,
decline,
downloadDocument,
};
};
@@ -0,0 +1,131 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 50px 20px;
background: #f5f5f5;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.card {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
color: #1a1a1a;
border: 1px solid #e5e7eb;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
.iconSuccess {
color: #2e7d32;
}
.iconError {
color: #d32f2f;
}
.iconNeutral {
color: #4b5563;
}
.title {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
.message {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.loadingExtra {
color: #6b7280;
margin-top: 12px;
font-size: 14px;
}
.errorBox {
margin-top: 20px;
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 8px;
padding: 16px;
color: #c62828;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.page {
background: #1a1a1a;
color: #e0e0e0;
}
.card {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
color: #e5e7eb;
border-color: #374151;
}
.iconSuccess {
color: #66bb6a;
}
.iconError {
color: #ef5350;
}
.iconNeutral {
color: #9ca3af;
}
.title {
color: #f5f5f5;
}
.message,
.loadingExtra {
color: #b0b0b0;
}
.errorBox {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
.page {
padding: 20px 16px;
}
.card {
padding: 32px 24px;
}
.title {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
@@ -0,0 +1,263 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import AuthCallback from "@app/routes/AuthCallback";
import {
POST_LOGIN_REDIRECT_STORAGE_KEY,
springAuth,
} from "@app/auth/springAuthClient";
// Mock springAuth; keep the real redirect-path helpers.
vi.mock("@app/auth/springAuthClient", async () => {
const actual = await vi.importActual<
typeof import("@app/auth/springAuthClient")
>("@app/auth/springAuthClient");
return {
...actual,
springAuth: {
getSession: vi.fn(),
},
};
});
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe("AuthCallback", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
// Reset window.location.hash
window.location.hash = "";
});
it("should extract JWT from URL hash and validate it", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
// Set URL hash with access token
window.location.hash = `#access_token=${mockToken}`;
// Mock successful session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
// Verify JWT was stored
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
// Verify getSession was called to validate token
expect(springAuth.getSession).toHaveBeenCalled();
// Verify navigation to home
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should redirect to login when no access token in hash", async () => {
// No hash or empty hash
window.location.hash = "";
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed - no token received." },
});
expect(localStorage.getItem("stirling_jwt")).toBeNull();
});
});
it("should redirect to login when token validation fails", async () => {
const invalidToken = "invalid-oauth-token";
window.location.hash = `#access_token=${invalidToken}`;
// Mock failed session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: { session: null },
error: { message: "Invalid token" },
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
// JWT should be stored initially
expect(localStorage.getItem("stirling_jwt")).toBeNull(); // Cleared after validation failure
// Verify redirect to login
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed - invalid token." },
});
});
});
it("should handle errors gracefully", async () => {
const mockToken = "error-token";
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error("Network error"),
);
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed. Please try again." },
});
});
});
it("should navigate to the stored post-login path when one is present", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
window.location.hash = `#access_token=${mockToken}`;
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/abc123");
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/share/abc123", {
replace: true,
});
});
// Stored path is single-use
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
});
it("should fall back to home when the stored post-login path is unsafe", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
window.location.hash = `#access_token=${mockToken}`;
// Protocol-relative URL — must not be followed
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "//evil.example");
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
});
it("should display loading state while processing", () => {
window.location.hash = "#access_token=processing-token";
vi.mocked(springAuth.getSession).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: { session: null },
error: { message: "Token expired" },
}),
100,
),
),
);
const { getByText } = render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
expect(getByText("Completing authentication")).toBeInTheDocument();
});
});
@@ -0,0 +1,227 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
consumePostLoginRedirectPath,
springAuth,
} from "@app/auth/springAuthClient";
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
import styles from "@app/routes/AuthCallback.module.css";
/**
* OAuth Callback Handler
*
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
* We extract it, store in localStorage, and redirect to the home page.
*/
export default function AuthCallback() {
const navigate = useNavigate();
const processingRef = useRef(false);
// Log component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
return () => {
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
};
}, []);
useEffect(() => {
const handleCallback = async () => {
const startTime = performance.now();
const executionId = Math.random().toString(36).substring(7);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.log(
`[AuthCallback:${executionId}] Starting authentication callback`,
);
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
console.log(
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
);
console.log(
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
);
if (
typeof window !== "undefined" &&
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
"1"
) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
);
navigate("/login", {
replace: true,
state: { error: "You have been signed out. Please sign in again." },
});
return;
}
// Prevent double execution (React 18 Strict Mode + navigate dependency)
if (processingRef.current) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
);
console.warn(
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
);
return;
}
processingRef.current = true;
try {
console.log(
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
);
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get("access_token");
if (!token) {
console.error(
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
);
navigate("/login", {
replace: true,
state: { error: "OAuth login failed - no token received." },
});
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
);
console.log(
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
);
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
console.log(
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
);
console.log(
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
console.log(
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
);
// Validate the token and load user info
// This calls /api/v1/auth/me with the JWT to get user details
const { data, error } = await springAuth.getSession();
if (error || !data.session) {
console.error(
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
error,
);
localStorage.removeItem("stirling_jwt");
navigate("/login", {
replace: true,
state: { error: "OAuth login failed - invalid token." },
});
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
);
console.log(
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
);
await handleAuthCallbackSuccess(token);
console.log(
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
);
console.log(
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
);
// Wait for all context providers to process jwt-available event
// This prevents infinite render loop when coming from cross-domain SAML redirect
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
);
const target = consumePostLoginRedirectPath() ?? "/";
console.log(
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
);
navigate(target, { replace: true });
const duration = performance.now() - startTime;
console.log(
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
} catch (error) {
const duration = performance.now() - startTime;
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.error(
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
);
console.error(`[AuthCallback:${executionId}] Error:`, error);
console.error(
`[AuthCallback:${executionId}] Error name:`,
(error as Error)?.name,
);
console.error(
`[AuthCallback:${executionId}] Error message:`,
(error as Error)?.message,
);
console.error(
`[AuthCallback:${executionId}] Error stack:`,
(error as Error)?.stack,
);
console.error(
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
);
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
navigate("/login", {
replace: true,
state: { error: "OAuth login failed. Please try again." },
});
}
};
handleCallback();
}, []); // Empty deps - only run once on mount. navigate is stable, processingRef prevents double execution
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
<div className={styles.title}>Completing authentication</div>
<div className={styles.message}>
Please wait while we finish signing you in.
</div>
<div className={styles.loadingExtra}>
You can close this window once it completes.
</div>
</div>
</div>
);
}
@@ -0,0 +1,290 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Paper,
Center,
Loader,
TextInput,
PasswordInput,
Anchor,
} from "@mantine/core";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import { BASE_PATH } from "@app/constants/app";
import apiClient from "@app/services/apiClient";
interface InviteData {
email: string | null;
role: string;
expiresAt: string;
emailRequired: boolean;
}
export default function InviteAccept() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inviteData, setInviteData] = useState<InviteData | null>(null);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
useEffect(() => {
if (!token) {
setError(t("invite.invalidToken", "Invalid invitation link"));
setLoading(false);
return;
}
validateToken();
}, [token]);
const validateToken = async () => {
try {
setLoading(true);
const response = await apiClient.get<InviteData>(
`/api/v1/invite/validate/${token}`,
{
suppressErrorToast: true,
},
);
setInviteData(response.data);
setError(null);
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) ||
t("invite.validationError", "Failed to validate invitation link");
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
// Validate email if required
if (inviteData?.emailRequired) {
if (!email || email.trim().length === 0) {
setError(t("invite.emailRequired", "Email address is required"));
return;
}
if (!email.includes("@")) {
setError(t("invite.invalidEmail", "Invalid email address"));
return;
}
}
// Validate passwords
if (!password) {
setError(t("invite.passwordRequired", "Password is required"));
return;
}
if (password !== confirmPassword) {
setError(t("invite.passwordMismatch", "Passwords do not match"));
return;
}
try {
setSubmitting(true);
setError(null);
const formData = new FormData();
if (inviteData?.emailRequired) {
formData.append("email", email.trim().toLowerCase());
}
formData.append("password", password);
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
suppressErrorToast: true,
});
// Success - redirect to login
navigate("/login?messageType=accountCreated");
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) ||
t("invite.acceptError", "Failed to create account");
setError(errorMessage);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<AuthLayout>
<LoginHeader
title={t("invite.validating", "Validating invitation...")}
/>
<Center py="xl">
<Loader size="md" />
</Center>
</AuthLayout>
);
}
if (error && !inviteData) {
return (
<AuthLayout>
<LoginHeader
title={t("invite.invalidInvitation", "Invalid Invitation")}
/>
<ErrorMessage error={error} />
<div className="auth-section">
<button
type="button"
onClick={() => navigate("/login")}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t("invite.goToLogin", "Go to Login")}
</button>
</div>
</AuthLayout>
);
}
return (
<AuthLayout>
<LoginHeader
title={t("invite.welcomeTitle", "You've been invited!")}
subtitle={t(
"invite.welcomeSubtitle",
"Complete your account setup to get started",
)}
/>
{inviteData && !inviteData.emailRequired && (
<Paper
withBorder
p="md"
mb="lg"
bg="blue.0"
style={{ borderColor: "var(--mantine-color-blue-3)" }}
>
<Stack gap="xs" align="center">
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={500}
style={{ letterSpacing: "0.05em" }}
>
{t("invite.accountFor", "Creating account for")}
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</Text>
<Text size="xs" c="dimmed">
{t("invite.linkExpires", "Link expires")}:{" "}
{new Date(inviteData.expiresAt).toLocaleDateString()} at{" "}
{new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
</Paper>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
<Stack gap="md">
{inviteData?.emailRequired && (
<TextInput
label={t("invite.email", "Email address")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(
"invite.emailPlaceholder",
"Enter your email address",
)}
disabled={submitting}
required
autoComplete="email"
/>
)}
<PasswordInput
label={t("invite.choosePassword", "Choose a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("invite.passwordPlaceholder", "Enter your password")}
disabled={submitting}
required
autoComplete="new-password"
/>
<PasswordInput
label={t("invite.confirmPassword", "Confirm password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t(
"invite.confirmPasswordPlaceholder",
"Re-enter your password",
)}
disabled={submitting}
required
autoComplete="new-password"
/>
<div className="auth-section">
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting
? t("invite.creating", "Creating Account...")
: t("invite.createAccount", "Create Account")}
</button>
</div>
</Stack>
</form>
<Center mt="md">
<Text size="sm" c="dimmed">
{t("invite.alreadyHaveAccount", "Already have an account?")}{" "}
<Anchor
component="button"
type="button"
onClick={() => navigate("/login")}
c="dark"
>
{t("invite.signIn", "Sign in")}
</Anchor>
</Text>
</Center>
</AuthLayout>
);
}

Some files were not shown because too many files have changed in this diff Show More