Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
+82 -42
View File
@@ -1,7 +1,19 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
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";
import type {
Session,
User,
AuthError,
AuthChangeEvent,
} from "@app/auth/springAuthClient";
/**
* Auth Context Type
@@ -54,7 +66,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
setLoading(true);
setError(null);
console.debug("[Auth] refreshSession: start", { path: window.location.pathname });
console.debug("[Auth] refreshSession: start", {
path: window.location.pathname,
});
console.debug("[Auth] Refreshing session...");
const { data, error } = await springAuth.refreshSession();
@@ -110,9 +124,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const initializeAuth = async () => {
try {
console.debug(`[Auth:${mountId}] Initializing auth...`);
console.debug(`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`);
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")) {
if (
typeof window !== "undefined" &&
window.location.pathname.startsWith("/login")
) {
await clearPlatformAuthOnLoginInit();
}
@@ -134,12 +153,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setSession(data.session);
}
} catch (err) {
console.error("[Auth] Unexpected error during auth initialization:", 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}`);
console.debug(
`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`,
);
if (mounted) {
setLoading(false);
}
@@ -152,8 +176,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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`);
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...`);
@@ -165,40 +193,52 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// 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)`);
} = springAuth.onAuthStateChange(
async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(
`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`,
);
return;
}
}, 0);
});
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`);
@@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { 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";
import {
AxiosError,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios";
// Mock apiClient
vi.mock("@app/services/apiClient");
@@ -64,13 +68,19 @@ describe("SpringAuthClient", () => {
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,
});
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);
@@ -86,13 +96,19 @@ describe("SpringAuthClient", () => {
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,
});
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);
@@ -145,7 +161,9 @@ describe("SpringAuthClient", () => {
{ withCredentials: true },
);
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "jwt-available" }));
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
expect(result.user).toEqual(mockUser);
expect(result.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
@@ -336,7 +354,9 @@ describe("SpringAuthClient", () => {
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(result.error).toBeNull();
});
@@ -355,7 +375,9 @@ describe("SpringAuthClient", () => {
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(startOAuthNavigation).toHaveBeenCalledWith(
"/oauth2/authorization/github",
);
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
+108 -28
View File
@@ -39,7 +39,12 @@ function getHttpStatus(error: unknown): number | 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.response?.data?.error ||
error.response?.data?.message ||
error.message ||
fallback
);
}
return error instanceof Error ? error.message : fallback;
}
@@ -108,9 +113,16 @@ export interface AuthResponse {
error: AuthError | null;
}
export type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
export type AuthChangeEvent =
| "SIGNED_IN"
| "SIGNED_OUT"
| "TOKEN_REFRESHED"
| "USER_UPDATED";
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
type AuthChangeCallback = (
event: AuthChangeEvent,
session: Session | null,
) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
@@ -138,7 +150,9 @@ class SpringAuthClient {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
console.warn("[SpringAuth] Cannot decode token for adaptive intervals, using defaults");
console.warn(
"[SpringAuth] Cannot decode token for adaptive intervals, using defaults",
);
return;
}
@@ -146,7 +160,9 @@ class SpringAuthClient {
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");
console.warn(
"[SpringAuth] Token missing exp/iat claims, using default intervals",
);
return;
}
@@ -155,12 +171,18 @@ class SpringAuthClient {
// 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));
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));
this.tokenRefreshThresholdMs = Math.max(
30000,
Math.min(300000, tokenLifetimeMs / 4),
);
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
@@ -171,7 +193,10 @@ class SpringAuthClient {
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn("[SpringAuth] Failed to calculate adaptive intervals:", error);
console.warn(
"[SpringAuth] Failed to calculate adaptive intervals:",
error,
);
}
}
@@ -190,7 +215,10 @@ class SpringAuthClient {
return JSON.parse(atob(base64));
}
private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
private getTokenExpiry(token: string): {
expiresIn: number;
expiresAt: number;
} {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
@@ -198,8 +226,12 @@ class SpringAuthClient {
}
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));
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 {
@@ -227,7 +259,10 @@ class SpringAuthClient {
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
async getSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
try {
// Get JWT from localStorage
let token = localStorage.getItem("stirling_jwt");
@@ -265,7 +300,10 @@ class SpringAuthClient {
const session: Session = {
user: {
id: platformUser?.email || platformUser?.username || "desktop-saas-user",
id:
platformUser?.email ||
platformUser?.username ||
"desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
role: "USER",
@@ -334,7 +372,11 @@ class SpringAuthClient {
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: { email: string; password: string; mfaCode?: string }): Promise<AuthResponse> {
async signInWithPassword(credentials: {
email: string;
password: string;
mfaCode?: string;
}): Promise<AuthResponse> {
try {
const response = await apiClient.post(
"/api/v1/auth/login",
@@ -379,7 +421,11 @@ class SpringAuthClient {
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";
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
"Login failed";
return {
user: null,
session: null,
@@ -462,7 +508,10 @@ class SpringAuthClient {
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : "SSO redirect failed" },
error: {
message:
error instanceof Error ? error.message : "SSO redirect failed",
},
};
}
}
@@ -473,7 +522,10 @@ class SpringAuthClient {
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
window.sessionStorage.setItem(
"stirling_sso_auto_login_logged_out",
"1",
);
}
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
@@ -496,14 +548,18 @@ class SpringAuthClient {
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn("[SpringAuth] Failed to clear Supabase/local auth tokens", 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();
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=/;`;
}
@@ -515,7 +571,10 @@ class SpringAuthClient {
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn("[SpringAuth] Failed to run platform auth cleanup", cleanupError);
console.warn(
"[SpringAuth] Failed to run platform auth cleanup",
cleanupError,
);
}
// Notify listeners
@@ -529,7 +588,10 @@ class SpringAuthClient {
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn("[SpringAuth] Failed to run platform auth cleanup after error", cleanupError);
console.warn(
"[SpringAuth] Failed to run platform auth cleanup after error",
cleanupError,
);
}
return {
error: { message: getErrorMessage(error, "Logout failed") },
@@ -540,7 +602,10 @@ class SpringAuthClient {
/**
* Refresh JWT token
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
async refreshSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
try {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
@@ -556,7 +621,9 @@ class SpringAuthClient {
if (error || !data.session) {
return {
data: { session: null },
error: error || { message: "Token refresh failed - please log in again" },
error: error || {
message: "Token refresh failed - please log in again",
},
};
}
@@ -610,7 +677,10 @@ class SpringAuthClient {
// 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: "Token refresh failed - please log in again" },
};
}
return {
@@ -623,7 +693,9 @@ class SpringAuthClient {
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
onAuthStateChange(callback: AuthChangeCallback): {
data: { subscription: { unsubscribe: () => void } };
} {
this.listeners.push(callback);
return {
@@ -646,7 +718,10 @@ class SpringAuthClient {
try {
callback(event, session);
} catch (error) {
console.error("[SpringAuth] Error in auth state change listener:", error);
console.error(
"[SpringAuth] Error in auth state change listener:",
error,
);
}
});
}, 0);
@@ -665,9 +740,14 @@ class SpringAuthClient {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon (threshold is adaptive)
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
if (
timeUntilExpiry > 0 &&
timeUntilExpiry < this.tokenRefreshThresholdMs
) {
console.log(
"[SpringAuth] 🔄 Proactively refreshing token (expires in " + Math.floor(timeUntilExpiry / 1000) + "s)",
"[SpringAuth] 🔄 Proactively refreshing token (expires in " +
Math.floor(timeUntilExpiry / 1000) +
"s)",
);
await this.refreshSession();
}
@@ -1,4 +1,7 @@
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
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";
@@ -6,9 +9,16 @@ import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerIn
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return (
<CoreAppProviders appConfigRetryOptions={appConfigRetryOptions} appConfigProviderProps={appConfigProviderProps}>
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
<LicenseProvider>
<UpdateSeatsProvider>
@@ -1,10 +1,25 @@
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 {
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 {
ChangeUserPasswordRequest,
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ChangeUserPasswordModalProps {
@@ -16,7 +31,8 @@ interface ChangeUserPasswordModalProps {
}
function generateSecurePassword() {
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
const charset =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
const length = 14;
let password = "";
const charsetLength = charset.length;
@@ -63,7 +79,12 @@ export default function ChangeUserPasswordModal({
const handleGeneratePassword = () => {
const generated = generateSecurePassword();
setForm((prev) => ({ ...prev, newPassword: generated, confirmPassword: generated, generateRandom: true }));
setForm((prev) => ({
...prev,
newPassword: generated,
confirmPassword: generated,
generateRandom: true,
}));
};
const handleCopyPassword = async () => {
@@ -72,10 +93,19 @@ export default function ChangeUserPasswordModal({
await navigator.clipboard.writeText(form.newPassword);
alert({
alertType: "success",
title: t("workspace.people.changePassword.copiedToClipboard", "Password copied to clipboard"),
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") });
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.copyFailed",
"Failed to copy password",
),
});
}
};
@@ -102,13 +132,22 @@ export default function ChangeUserPasswordModal({
if (!form.generateRandom && !form.newPassword.trim()) {
alert({
alertType: "error",
title: t("workspace.people.changePassword.passwordRequired", "Please enter a new password"),
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") });
alert({
alertType: "error",
title: t(
"workspace.people.changePassword.passwordMismatch",
"Passwords do not match",
),
});
return;
}
@@ -124,14 +163,25 @@ export default function ChangeUserPasswordModal({
try {
setProcessing(true);
await userManagementService.changeUserPassword(payload);
alert({ alertType: "success", title: t("workspace.people.changePassword.success", "Password updated successfully") });
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.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.changePassword.error", "Failed to update password");
t(
"workspace.people.changePassword.error",
"Failed to update password",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -188,39 +238,80 @@ export default function ChangeUserPasswordModal({
/>
<Stack gap="lg" pt="md">
<Stack gap="md" align="center">
<LocalIcon icon="lock" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<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>
{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")}
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 })}
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")}
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 })}
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")
!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")}
label={t(
"workspace.people.changePassword.generateRandom",
"Generate secure password",
)}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
@@ -234,12 +325,30 @@ export default function ChangeUserPasswordModal({
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t("workspace.people.changePassword.generatedPreview", "Generated password:")}{" "}
{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" />
<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>
@@ -249,21 +358,42 @@ export default function ChangeUserPasswordModal({
<Stack gap="xs">
<Checkbox
label={t("workspace.people.changePassword.sendEmail", "Email the user about this change")}
label={t(
"workspace.people.changePassword.sendEmail",
"Email the user about this change",
)}
checked={canEmail && form.sendEmail}
onChange={(event) => setForm({ ...form, sendEmail: event.currentTarget.checked })}
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")}
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 })}
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")}
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 })}
onChange={(event) =>
setForm({
...form,
forcePasswordChange: event.currentTarget.checked,
})
}
disabled={processing || disabled}
/>
{!canEmail && (
@@ -289,7 +419,14 @@ export default function ChangeUserPasswordModal({
)}
</Stack>
<Button onClick={handleSubmit} loading={processing} fullWidth size="md" disabled={disabled} mt="md">
<Button
onClick={handleSubmit}
loading={processing}
fullWidth
size="md"
disabled={disabled}
mt="md"
>
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
@@ -19,11 +19,17 @@ export default function DividerWithText({
}: TextDividerProps) {
const variantClass = variant === "subcategory" ? "subcategory" : "";
const themeClass = respondsToDarkMode ? "" : "force-light";
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ["--text-divider-opacity" as string]: opacity } : style;
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 ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
@@ -31,5 +37,10 @@ export default function DividerWithText({
);
}
return <div className={`h-px my-2.5 ${themeClass} ${className}`} style={styleWithOpacity} />;
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
);
}
@@ -31,14 +31,22 @@ interface InviteMembersModalProps {
onSuccess?: () => void;
}
export default function InviteMembersModal({ opened, onClose, onSuccess }: InviteMembersModalProps) {
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 [inviteMode, setInviteMode] = useState<"email" | "direct" | "link">(
"direct",
);
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(
null,
);
const actionTakenRef = useRef(false);
// License information
@@ -84,7 +92,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
if (opened) {
const fetchData = async () => {
try {
const [adminData, teamsData] = await Promise.all([userManagementService.getUsers(), teamService.getTeams()]);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setTeams(teamsData);
@@ -122,13 +133,22 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
const handleInviteUser = async () => {
if (!inviteForm.username) {
alert({ alertType: "error", title: t("workspace.people.addMember.usernameRequired") });
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") });
alert({
alertType: "error",
title: t(
"workspace.people.addMember.passwordRequired",
"Password is required",
),
});
return;
}
@@ -143,7 +163,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
forceChange: inviteForm.forceChange,
forceMFA: inviteForm.forceMFA,
});
alert({ alertType: "success", title: t("workspace.people.addMember.success") });
alert({
alertType: "success",
title: t("workspace.people.addMember.success"),
});
onClose();
onSuccess?.();
// Reset form
@@ -159,8 +182,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
} 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");
? 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);
@@ -169,7 +195,13 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: "error", title: t("workspace.people.emailInvite.emailsRequired", "Email addresses are required") });
alert({
alertType: "error",
title: t(
"workspace.people.emailInvite.emailsRequired",
"Email addresses are required",
),
});
return;
}
@@ -195,7 +227,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
if (response.failureCount > 0 && response.errors) {
alert({
alertType: "warning",
title: t("workspace.people.emailInvite.partialFailure", "Some invites failed"),
title: t(
"workspace.people.emailInvite.partialFailure",
"Some invites failed",
),
body: response.errors,
});
}
@@ -210,14 +245,19 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
} else {
alert({
alertType: "error",
title: t("workspace.people.emailInvite.allFailed", "Failed to invite users"),
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.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 });
@@ -242,15 +282,23 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({
alertType: "success",
title: t("workspace.people.inviteLink.emailSent", "Invite link generated and sent via email"),
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.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");
t(
"workspace.people.inviteLink.error",
"Failed to generate invite link",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -327,7 +375,12 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.inviteMembers.label", "Invite Members")}
</Text>
@@ -346,19 +399,30 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<Paper
withBorder
p="sm"
bg={licenseInfo.availableSlots === 0 ? "var(--mantine-color-red-light)" : "var(--mantine-color-blue-light)"}
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" />
<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")}
: t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
</Text>
</Group>
{licenseInfo.availableSlots === 0 && (
@@ -398,7 +462,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
}}
data={[
{
label: t("workspace.people.inviteMode.username", "Username"),
label: t(
"workspace.people.inviteMode.username",
"Username",
),
value: "direct",
},
{
@@ -420,10 +487,21 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
{inviteMode === "link" && (
<>
<TextInput
label={t("workspace.people.inviteLink.email", "Email (optional)")}
placeholder={t("workspace.people.inviteLink.emailPlaceholder", "[email protected]")}
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 })}
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",
@@ -433,35 +511,67 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
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 }}
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 })}
onChange={(value) =>
setInviteLinkForm({
...inviteLinkForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<TextInput
label={t("workspace.people.inviteLink.expiryHours", "Link expires in (hours)")}
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 })}
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")}
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 })}
onChange={(e) =>
setInviteLinkForm({
...inviteLinkForm,
sendEmail: e.currentTarget.checked,
})
}
/>
)}
@@ -470,18 +580,30 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<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")}
{t(
"workspace.people.inviteLink.generated",
"Invite Link Generated",
)}
</Text>
<Group gap="xs">
<TextInput value={generatedInviteLink} readOnly style={{ flex: 1 }} />
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
await navigator.clipboard.writeText(
generatedInviteLink,
);
alert({
alertType: "success",
title: t("workspace.people.inviteLink.copied", "Link copied to clipboard!"),
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
} catch {
// Fallback for browsers without clipboard API
@@ -495,12 +617,19 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
document.body.removeChild(textArea);
alert({
alertType: "success",
title: t("workspace.people.inviteLink.copied", "Link copied to clipboard!"),
title: t(
"workspace.people.inviteLink.copied",
"Link copied to clipboard!",
),
});
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
<LocalIcon
icon="content-copy"
width="1rem"
height="1rem"
/>
</Button>
</Group>
</Stack>
@@ -513,10 +642,21 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
{inviteMode === "email" && config?.enableEmailInvites && (
<>
<Textarea
label={t("workspace.people.emailInvite.emails", "Email Addresses")}
placeholder={t("workspace.people.emailInvite.emailsPlaceholder", "[email protected], [email protected]")}
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 })}
onChange={(e) =>
setEmailInviteForm({
...emailInviteForm,
emails: e.currentTarget.value,
})
}
minRows={3}
required
/>
@@ -524,17 +664,33 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
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 }}
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 })}
onChange={(value) =>
setEmailInviteForm({
...emailInviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
@@ -544,31 +700,71 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<>
<TextInput
label={t("workspace.people.addMember.username")}
placeholder={t("workspace.people.addMember.usernamePlaceholder")}
placeholder={t(
"workspace.people.addMember.usernamePlaceholder",
)}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
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")}
label={t(
"workspace.people.addMember.authType",
"Authentication Type",
)}
data={[
{ value: "WEB", label: t("workspace.people.authType.password", "Password") },
{
value: "WEB",
label: t(
"workspace.people.authType.password",
"Password",
),
},
...(config?.enableOAuth
? [{ value: "OAUTH2", label: t("workspace.people.authType.oauth", "OAuth2") }]
? [
{
value: "OAUTH2",
label: t(
"workspace.people.authType.oauth",
"OAuth2",
),
},
]
: []),
...(config?.enableSaml
? [
{
value: "SAML2",
label: t("workspace.people.authType.saml", "SAML2"),
},
]
: []),
...(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" })
setInviteForm({
...inviteForm,
authType: (value as "WEB" | "OAUTH2" | "SAML2") || "WEB",
})
}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
description={
inviteForm.authType !== "WEB"
? t("workspace.people.authType.ssoDescription", "User will authenticate via SSO provider")
? t(
"workspace.people.authType.ssoDescription",
"User will authenticate via SSO provider",
)
: undefined
}
/>
@@ -580,20 +776,43 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<TextInput
label={t("workspace.people.addMember.password")}
type="password"
placeholder={t("workspace.people.addMember.passwordPlaceholder")}
placeholder={t(
"workspace.people.addMember.passwordPlaceholder",
)}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
onChange={(e) =>
setInviteForm({
...inviteForm,
password: e.currentTarget.value,
})
}
required
/>
<Checkbox
label={t("workspace.people.addMember.forcePasswordChange", "Force password change on first login")}
label={t(
"workspace.people.addMember.forcePasswordChange",
"Force password change on first login",
)}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceChange: e.currentTarget.checked,
})
}
/>
<Checkbox
label={t("workspace.people.addMember.forceMFA", "Force MFA setup on next login")}
label={t(
"workspace.people.addMember.forceMFA",
"Force MFA setup on next login",
)}
checked={inviteForm.forceMFA}
onChange={(e) => setInviteForm({ ...inviteForm, forceMFA: e.currentTarget.checked })}
onChange={(e) =>
setInviteForm({
...inviteForm,
forceMFA: e.currentTarget.checked,
})
}
/>
</>
)}
@@ -602,23 +821,42 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
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 }}
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 })}
onChange={(value) =>
setInviteForm({
...inviteForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
</>
)}
{/* Action Button */}
<Button onClick={handlePrimaryAction} loading={!hasNoSlots && processing} fullWidth size="md" mt="md">
<Button
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === "email"
? t("workspace.people.emailInvite.submit", "Send Invites")
: inviteMode === "link"
@@ -28,7 +28,10 @@ function LoginRightCarousel({
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return [];
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000);
return imageSlides.map(
(_, i) =>
(i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000,
);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
@@ -51,7 +54,17 @@ function LoginRightCarousel({
return () => window.removeEventListener("mousemove", onMove);
}, []);
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
function TiltImage({
src,
alt,
enabled,
maxDeg = 6,
}: {
src: string;
alt?: string;
enabled: boolean;
maxDeg?: number;
}) {
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
@@ -94,12 +107,25 @@ function LoginRightCarousel({
}
return (
<div style={{ position: "relative", overflow: "hidden", width: "100%", height: "100%" }}>
<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" }}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
@@ -144,13 +170,24 @@ function LoginRightCarousel({
</div>
)}
{s.subtitle && (
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", textShadow: "0 1px 4px rgba(0,0,0,0.25)" }}>
<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} />
<TiltImage
src={s.src}
alt={s.alt}
enabled={index === idx && !!s.followMouseTilt}
maxDeg={s.tiltMaxDeg ?? 6}
/>
</div>
))}
@@ -178,7 +215,8 @@ function LoginRightCarousel({
borderRadius: "50%",
border: "none",
cursor: "pointer",
backgroundColor: i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
backgroundColor:
i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "block",
flexShrink: 0,
@@ -8,7 +8,9 @@ interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({ returnUrl = window.location.href }) => {
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -20,11 +22,16 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({ return
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo.licenseKey) {
throw new Error("No license key found. Please activate a license first.");
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);
const response = await licenseService.createBillingPortalSession(
returnUrl,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
@@ -34,7 +41,9 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({ return
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.",
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
setLoading(false);
}
@@ -3,12 +3,19 @@ import { Button, ButtonProps } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext";
interface UpdateSeatsButtonProps extends Omit<ButtonProps, "onClick" | "loading"> {
interface UpdateSeatsButtonProps extends Omit<
ButtonProps,
"onClick" | "loading"
> {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({ onSuccess, onError, ...buttonProps }) => {
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
onSuccess,
onError,
...buttonProps
}) => {
const { t } = useTranslation();
const { openUpdateSeats, isLoading } = useUpdateSeats();
@@ -20,7 +27,12 @@ export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({ onSuccess,
};
return (
<Button variant="outline" onClick={handleClick} loading={isLoading} {...buttonProps}>
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
);
@@ -1,5 +1,14 @@
import React, { useState, useEffect } from "react";
import { Modal, Button, Text, Alert, Loader, Stack, Group, NumberInput } from "@mantine/core";
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";
@@ -51,9 +60,13 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
if (newSeatCount < minimumSeats) {
setState({
status: "error",
error: t("billing.seatCountTooLow", "Seat count must be at least {{minimum}} (current number of users)", {
minimum: minimumSeats,
}),
error: t(
"billing.seatCountTooLow",
"Seat count must be at least {{minimum}} (current number of users)",
{
minimum: minimumSeats,
},
),
});
return;
}
@@ -61,7 +74,10 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
if (newSeatCount === currentSeats) {
setState({
status: "error",
error: t("billing.seatCountUnchanged", "Please select a different seat count"),
error: t(
"billing.seatCountUnchanged",
"Please select a different seat count",
),
});
return;
}
@@ -79,7 +95,8 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
// 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";
const errorMessage =
err instanceof Error ? err.message : "Failed to update seat count";
setState({
status: "error",
error: errorMessage,
@@ -135,9 +152,14 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
<NumberInput
label={t("billing.newSeatCount", "New Seat Count")}
description={t("billing.newSeatCountDescription", "Select the number of seats for your enterprise license")}
description={t(
"billing.newSeatCountDescription",
"Select the number of seats for your enterprise license",
)}
value={newSeatCount}
onChange={(value) => setNewSeatCount(typeof value === "number" ? value : minimumSeats)}
onChange={(value) =>
setNewSeatCount(typeof value === "number" ? value : minimumSeats)
}
min={minimumSeats}
max={10000}
step={1}
@@ -151,7 +173,10 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
}}
/>
<Alert color="blue" title={t("billing.whatHappensNext", "What happens next?")}>
<Alert
color="blue"
title={t("billing.whatHappensNext", "What happens next?")}
>
<Text size="sm">
{t(
"billing.stripePortalRedirect",
@@ -164,7 +189,12 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
<Button variant="outline" onClick={handleClose}>
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={handleUpdateSeats} disabled={newSeatCount === currentSeats || newSeatCount < minimumSeats}>
<Button
onClick={handleUpdateSeats}
disabled={
newSeatCount === currentSeats || newSeatCount < minimumSeats
}
>
{t("billing.updateSeats", "Update Seats")}
</Button>
</Group>
@@ -48,7 +48,8 @@ const UpgradeBanner: React.FC = () => {
return Date.now() - lastShown >= WEEK_IN_MS;
});
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
const [testScenario, setTestScenario] =
useState<UpgradeBannerTestScenario>(null);
useEffect(() => {
if (!isDev || typeof window === "undefined") {
@@ -66,9 +67,15 @@ const UpgradeBanner: React.FC = () => {
}
};
window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
window.addEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
return () => {
window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
window.removeEventListener(
UPGRADE_BANNER_TEST_EVENT,
handleTestEvent as EventListener,
);
};
}, [isDev]);
@@ -80,21 +87,44 @@ const UpgradeBanner: React.FC = () => {
const userCountKnown = typeof totalUsers === "number";
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
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 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 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
@@ -103,17 +133,26 @@ const UpgradeBanner: React.FC = () => {
: effectiveIsAdmin;
const shouldShowFriendlyBase = Boolean(
isDerivedAdmin && !effectiveHasPaidLicense && effectiveIsUnderLimit && effectiveTotalUsersLoaded,
isDerivedAdmin &&
!effectiveHasPaidLicense &&
effectiveIsUnderLimit &&
effectiveTotalUsersLoaded,
);
const shouldShowUrgentBase = Boolean(
!effectiveHasPaidLicense &&
effectiveTotalUsersLoaded &&
(effectiveIsOverLimit || scenarioKey === "login-user-over-limit-no-license"),
(effectiveIsOverLimit ||
scenarioKey === "login-user-over-limit-no-license"),
);
const shouldEvaluateFriendly = scenario
? scenarioIsFriendly
: Boolean(shouldShowFriendlyBase && !licenseLoading && effectiveTotalUsersLoaded && onboardingComplete);
: Boolean(
shouldShowFriendlyBase &&
!licenseLoading &&
effectiveTotalUsersLoaded &&
onboardingComplete,
);
// Urgent banner should always show when over-limit
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
@@ -154,13 +193,25 @@ const UpgradeBanner: React.FC = () => {
}
: { active: false };
window.dispatchEvent(new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }));
}, [shouldEvaluateUrgent, effectiveIsAdmin, effectiveTotalUsers, scenario, freeTierLimit]);
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 } }));
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, {
detail: { active: false },
}),
);
}
};
}, []);
@@ -192,7 +243,10 @@ const UpgradeBanner: React.FC = () => {
},
});
} catch (error) {
console.error("[UpgradeBanner] Failed to open checkout, redirecting instead", error);
console.error(
"[UpgradeBanner] Failed to open checkout, redirecting instead",
error,
);
navigateFallback();
return;
}
@@ -221,7 +275,9 @@ const UpgradeBanner: React.FC = () => {
deferUntilTourComplete: false,
};
window.dispatchEvent(new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }));
window.dispatchEvent(
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
);
};
const renderUrgentBanner = () => {
@@ -229,17 +285,28 @@ const UpgradeBanner: React.FC = () => {
return null;
}
const buttonText = effectiveIsAdmin ? t("upgradeBanner.seeInfo", "See info") : undefined;
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.");
? 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")}
title={t(
"upgradeBanner.attentionTitle",
"This server needs admin attention",
)}
message={attentionMessage}
buttonText={buttonText}
buttonIcon="info-rounded"
@@ -256,12 +323,18 @@ const UpgradeBanner: React.FC = () => {
);
};
const suppressForNoLogin = !loginEnabled || (!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
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)) {
if (
onAuthRoute ||
suppressForNoLogin ||
(!friendlyVisible && !shouldEvaluateUrgent)
) {
return null;
}
@@ -271,7 +344,10 @@ const UpgradeBanner: React.FC = () => {
<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.")}
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}
@@ -10,15 +10,23 @@ interface EnterpriseRequiredBannerProps {
/**
* Banner that explains enterprise-only features are in demo mode
*/
export default function EnterpriseRequiredBanner({ show, featureName }: EnterpriseRequiredBannerProps) {
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")}
icon={
<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />
}
title={t(
"admin.settings.enterpriseRequired.title",
"Enterprise License Required",
)}
color="yellow"
variant="light"
styles={{
@@ -19,13 +19,23 @@ export function OverviewHeader() {
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "0.5rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<div>
<Text fw={600} size="lg">
{t("config.overview.title", "Application Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t("config.overview.description", "Current application settings and configuration details.")}
{t(
"config.overview.description",
"Current application settings and configuration details.",
)}
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
@@ -38,10 +38,14 @@ export const useConfigNavSections = (
const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add account management under Preferences
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === "general"));
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general" ? { ...item, component: <GeneralSection /> } : item,
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
@@ -57,8 +61,14 @@ export const useConfigNavSections = (
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
const requiresLogin = !loginEnabled;
const enableLoginTooltip = t("settings.tooltips.enableLoginFirst", "Enable login mode first");
const requiresEnterpriseTooltip = t("settings.tooltips.requiresEnterprise", "Requires Enterprise license");
const enableLoginTooltip = t(
"settings.tooltips.enableLoginFirst",
"Enable login mode first",
);
const requiresEnterpriseTooltip = t(
"settings.tooltips.requiresEnterprise",
"Requires Enterprise license",
);
// Workspace
sections.push({
@@ -105,7 +115,10 @@ export const useConfigNavSections = (
},
{
key: "adminStorageSharing",
label: t("settings.configuration.storageSharing", "File Storage & Sharing"),
label: t(
"settings.configuration.storageSharing",
"File Storage & Sharing",
),
icon: "storage-rounded",
component: <AdminStorageSharingSection />,
disabled: requiresLogin,
@@ -181,15 +194,22 @@ export const useConfigNavSections = (
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
{
key: "adminUsage",
label: t("settings.licensingAnalytics.usageAnalytics", "Usage Analytics"),
label: t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip,
disabledTooltip: requiresLogin
? enableLoginTooltip
: requiresEnterpriseTooltip,
},
],
});
@@ -249,16 +269,26 @@ export const createConfigNavSections = (
runningEE: boolean = false,
loginEnabled: boolean = false,
): ConfigNavSection[] => {
console.warn("createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.");
console.warn(
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
);
// Get the core sections (just Preferences)
const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
const sections = createCoreConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
// Add account management under Preferences
const preferencesSection = sections.find((section) => section.items.some((item) => item.key === "general"));
const preferencesSection = sections.find((section) =>
section.items.some((item) => item.key === "general"),
);
if (preferencesSection) {
preferencesSection.items = preferencesSection.items.map((item) =>
item.key === "general" ? { ...item, component: <GeneralSection /> } : item,
item.key === "general"
? { ...item, component: <GeneralSection /> }
: item,
);
if (loginEnabled) {
@@ -285,7 +315,9 @@ export const createConfigNavSections = (
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "teams",
@@ -293,7 +325,9 @@ export const createConfigNavSections = (
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
@@ -308,7 +342,9 @@ export const createConfigNavSections = (
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminFeatures",
@@ -316,7 +352,9 @@ export const createConfigNavSections = (
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminEndpoints",
@@ -324,7 +362,9 @@ export const createConfigNavSections = (
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminDatabase",
@@ -332,7 +372,9 @@ export const createConfigNavSections = (
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAdvanced",
@@ -340,7 +382,9 @@ export const createConfigNavSections = (
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
@@ -355,7 +399,9 @@ export const createConfigNavSections = (
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminConnections",
@@ -363,7 +409,9 @@ export const createConfigNavSections = (
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
@@ -378,7 +426,9 @@ export const createConfigNavSections = (
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminAudit",
@@ -386,7 +436,9 @@ export const createConfigNavSections = (
icon: "fact-check-rounded",
component: <AdminAuditSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : "Requires Enterprise license",
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
{
key: "adminUsage",
@@ -394,7 +446,9 @@ export const createConfigNavSections = (
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : "Requires Enterprise license",
disabledTooltip: requiresLogin
? "Enable login mode first"
: "Requires Enterprise license",
},
],
});
@@ -409,7 +463,9 @@ export const createConfigNavSections = (
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
{
key: "adminPrivacy",
@@ -417,7 +473,9 @@ export const createConfigNavSections = (
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
disabledTooltip: requiresLogin
? "Enable login mode first"
: undefined,
},
],
});
@@ -446,4 +504,8 @@ export const createConfigNavSections = (
};
// Re-export types for convenience
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from "@core/components/shared/config/configNavSections";
export type {
ConfigNavSection,
ConfigNavItem,
ConfigColors,
} from "@core/components/shared/config/configNavSections";
@@ -1,5 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
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";
@@ -24,34 +35,49 @@ const AccountSection: React.FC = () => {
const [passwordError, setPasswordError] = useState("");
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState("");
const [currentPasswordForUsername, setCurrentPasswordForUsername] =
useState("");
const [newUsername, setNewUsername] = useState("");
const [usernameError, setUsernameError] = useState("");
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [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 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;
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(),
() =>
(user?.authenticationType ?? authTypeFromMetadata ?? "").toLowerCase(),
[authTypeFromMetadata, user?.authenticationType],
);
const isSsoUser = useMemo(() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType), [normalizedAuthType]);
const isSsoUser = useMemo(
() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType),
[normalizedAuthType],
);
const userIdentifier = useMemo(() => user?.email || user?.username || "", [user?.email, user?.username]);
const userIdentifier = useMemo(
() => user?.email || user?.username || "",
[user?.email, user?.username],
);
const redirectToLogin = useCallback(() => {
window.location.assign("/login");
@@ -65,17 +91,26 @@ const AccountSection: React.FC = () => {
event.preventDefault();
if (isSsoUser) {
setPasswordError(t("settings.security.password.ssoDisabled", "Password changes are managed by your identity provider."));
setPasswordError(
t(
"settings.security.password.ssoDisabled",
"Password changes are managed by your identity provider.",
),
);
return;
}
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError(t("settings.security.password.required", "All fields are required."));
setPasswordError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
if (newPassword !== confirmPassword) {
setPasswordError(t("settings.security.password.mismatch", "New passwords do not match."));
setPasswordError(
t("settings.security.password.mismatch", "New passwords do not match."),
);
return;
}
@@ -87,7 +122,10 @@ const AccountSection: React.FC = () => {
showToast({
alertType: "success",
title: t("settings.security.password.success", "Password updated successfully. Please sign in again."),
title: t(
"settings.security.password.success",
"Password updated successfully. Please sign in again.",
),
});
setCurrentPassword("");
@@ -142,7 +180,10 @@ const AccountSection: React.FC = () => {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t("account.mfa.setupFailed", "Unable to start two-factor setup. Please try again."),
t(
"account.mfa.setupFailed",
"Unable to start two-factor setup. Please try again.",
),
);
} finally {
setMfaLoading(false);
@@ -153,7 +194,12 @@ const AccountSection: React.FC = () => {
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError(t("account.mfa.codeRequired", "Enter the authentication code to continue."));
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
@@ -172,7 +218,10 @@ const AccountSection: React.FC = () => {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t("account.mfa.enableFailed", "Unable to enable two-factor authentication. Check the code and try again."),
t(
"account.mfa.enableFailed",
"Unable to enable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
@@ -185,7 +234,12 @@ const AccountSection: React.FC = () => {
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaDisableCode.trim()) {
setMfaError(t("account.mfa.codeRequired", "Enter the authentication code to continue."));
setMfaError(
t(
"account.mfa.codeRequired",
"Enter the authentication code to continue.",
),
);
return;
}
try {
@@ -197,13 +251,19 @@ const AccountSection: React.FC = () => {
setMfaDisableCode("");
showToast({
alertType: "success",
title: t("account.mfa.disabled", "Two-factor authentication disabled."),
title: t(
"account.mfa.disabled",
"Two-factor authentication disabled.",
),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t("account.mfa.disableFailed", "Unable to disable two-factor authentication. Check the code and try again."),
t(
"account.mfa.disableFailed",
"Unable to disable two-factor authentication. Check the code and try again.",
),
);
} finally {
setMfaLoading(false);
@@ -234,12 +294,19 @@ const AccountSection: React.FC = () => {
event.preventDefault();
if (isSsoUser) {
setUsernameError(t("changeCreds.ssoManaged", "Your account is managed by your identity provider."));
setUsernameError(
t(
"changeCreds.ssoManaged",
"Your account is managed by your identity provider.",
),
);
return;
}
if (!currentPasswordForUsername || !newUsername) {
setUsernameError(t("settings.security.password.required", "All fields are required."));
setUsernameError(
t("settings.security.password.required", "All fields are required."),
);
return;
}
@@ -247,12 +314,18 @@ const AccountSection: React.FC = () => {
setUsernameSubmitting(true);
setUsernameError("");
await accountService.changeUsername(newUsername, currentPasswordForUsername);
await accountService.changeUsername(
newUsername,
currentPasswordForUsername,
);
showToast({
alertType: "success",
title: t("changeCreds.credsUpdated", "Account updated"),
body: t("changeCreds.description", "Changes saved. Please log in again."),
body: t(
"changeCreds.description",
"Changes saved. Please log in again.",
),
});
setNewUsername("");
@@ -263,7 +336,10 @@ const AccountSection: React.FC = () => {
const axiosError = err as { response?: { data?: { message?: string } } };
setUsernameError(
axiosError.response?.data?.message ||
t("changeCreds.error", "Unable to update username. Please verify your password and try again."),
t(
"changeCreds.error",
"Unable to update username. Please verify your password and try again.",
),
);
} finally {
setUsernameSubmitting(false);
@@ -291,14 +367,24 @@ const AccountSection: React.FC = () => {
<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
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)}>
<Button
leftSection={<LocalIcon icon="key-rounded" />}
onClick={() => setPasswordModalOpen(true)}
>
{t("settings.security.password.update", "Update password")}
</Button>
)}
@@ -313,7 +399,12 @@ const AccountSection: React.FC = () => {
</Button>
)}
<Button variant="outline" color="red" leftSection={<LocalIcon icon="logout-rounded" />} onClick={handleLogout}>
<Button
variant="outline"
color="red"
leftSection={<LocalIcon icon="logout-rounded" />}
onClick={handleLogout}
>
{t("settings.general.logout", "Log out")}
</Button>
</Group>
@@ -323,24 +414,41 @@ const AccountSection: React.FC = () => {
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Text fw={600}>{t("account.mfa.title", "Two-factor authentication")}</Text>
<Text fw={600}>
{t("account.mfa.title", "Two-factor authentication")}
</Text>
<Text size="sm" c="dimmed">
{t("account.mfa.description", "Add an extra layer of security to your account.")}
{t(
"account.mfa.description",
"Add an extra layer of security to your account.",
)}
</Text>
{isSsoUser ? (
<Alert icon={<LocalIcon icon="info" width="1rem" height="1rem" />} color="blue" variant="light">
{t("account.mfa.ssoManaged", "Two-factor authentication for this account is managed by your identity provider.")}
<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" />}
leftSection={
<LocalIcon icon="check-circle-outline-rounded" />
}
onClick={handleStartMfaSetup}
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t("account.mfa.enableButton", "Enable two-factor authentication")}
{t(
"account.mfa.enableButton",
"Enable two-factor authentication",
)}
</Button>
) : (
<Button
@@ -354,7 +462,10 @@ const AccountSection: React.FC = () => {
}}
disabled={changeButtonDisabled}
>
{t("account.mfa.disableButton", "Disable two-factor authentication")}
{t(
"account.mfa.disableButton",
"Disable two-factor authentication",
)}
</Button>
)}
</Group>
@@ -372,44 +483,79 @@ const AccountSection: React.FC = () => {
<form onSubmit={handlePasswordSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("settings.security.password.subtitle", "Change your password. You will be logged out after updating.")}
{t(
"settings.security.password.subtitle",
"Change your password. You will be logged out after updating.",
)}
</Text>
{passwordError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<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")}
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)}
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")}
placeholder={t(
"settings.security.password.newPlaceholder",
"Enter a new password",
)}
value={newPassword}
onChange={(event) => setNewPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t("settings.security.password.confirm", "Confirm new password")}
placeholder={t("settings.security.password.confirmPlaceholder", "Re-enter your new password")}
label={t(
"settings.security.password.confirm",
"Confirm new password",
)}
placeholder={t(
"settings.security.password.confirmPlaceholder",
"Re-enter your new password",
)}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.currentTarget.value)}
onChange={(event) =>
setConfirmPassword(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPasswordModalOpen(false)}>
<Button
variant="default"
onClick={() => setPasswordModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={passwordSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
<Button
type="submit"
loading={passwordSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("settings.security.password.update", "Update password")}
</Button>
</Group>
@@ -455,7 +601,8 @@ const AccountSection: React.FC = () => {
/>
</Box>
<Text size="sm" c="dimmed">
{t("account.mfa.manualKey", "Manual setup key")}: <strong>{mfaSetupData.secret}</strong>
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
<strong>{mfaSetupData.secret}</strong>
</Text>
<Text size="xs" c="orange">
{t(
@@ -466,13 +613,22 @@ const AccountSection: React.FC = () => {
</Stack>
)}
{mfaError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<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")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaSetupCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
@@ -499,23 +655,38 @@ const AccountSection: React.FC = () => {
<Modal
opened={mfaDisableModalOpen}
onClose={handleCloseMfaDisableModal}
title={t("account.mfa.disableTitle", "Disable two-factor authentication")}
title={t(
"account.mfa.disableTitle",
"Disable two-factor authentication",
)}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleDisableMfa}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("account.mfa.disableDescription", "Enter a valid authentication code to disable two-factor authentication.")}
{t(
"account.mfa.disableDescription",
"Enter a valid authentication code to disable two-factor authentication.",
)}
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<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")}
placeholder={t(
"account.mfa.codePlaceholder",
"Enter 6-digit code",
)}
value={mfaDisableCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
@@ -549,11 +720,20 @@ const AccountSection: React.FC = () => {
<form onSubmit={handleUsernameSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("changeCreds.changeUsername", "Update your username. You will be logged out after updating.")}
{t(
"changeCreds.changeUsername",
"Update your username. You will be logged out after updating.",
)}
</Text>
{usernameError && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{usernameError}
</Alert>
)}
@@ -562,7 +742,9 @@ const AccountSection: React.FC = () => {
label={t("changeCreds.newUsername", "New Username")}
placeholder={t("changeCreds.newUsername", "New Username")}
value={newUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setNewUsername(event.currentTarget.value)
}
required
/>
@@ -577,10 +759,17 @@ const AccountSection: React.FC = () => {
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setUsernameModalOpen(false)}>
<Button
variant="default"
onClick={() => setUsernameModalOpen(false)}
>
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={usernameSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
<Button
type="submit"
loading={usernameSubmitting}
leftSection={<LocalIcon icon="save-rounded" />}
>
{t("common.save", "Save")}
</Button>
</Group>
@@ -1,9 +1,19 @@
import React, { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { Tabs, Loader, Alert, Stack, Text, Button, Accordion } from "@mantine/core";
import {
Tabs,
Loader,
Alert,
Stack,
Text,
Button,
Accordion,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import auditService, { AuditSystemStatus as AuditStatus } from "@app/services/auditService";
import auditService, {
AuditSystemStatus as AuditStatus,
} from "@app/services/auditService";
import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus";
import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards";
import AuditChartsSection from "@app/components/shared/config/configSections/audit/AuditChartsSection";
@@ -27,7 +37,9 @@ const AdminAuditSection: React.FC = () => {
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">("week");
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">(
"week",
);
useEffect(() => {
const fetchSystemStatus = async () => {
@@ -42,7 +54,11 @@ const AdminAuditSection: React.FC = () => {
if (status === 403 || status === 404) {
setError("enterprise-license-required");
} else {
setError(err instanceof Error ? err.message : "Failed to load audit system status");
setError(
err instanceof Error
? err.message
: "Failed to load audit system status",
);
}
} finally {
setLoading(false);
@@ -73,7 +89,14 @@ const AdminAuditSection: React.FC = () => {
if (actualLoading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
@@ -82,7 +105,10 @@ const AdminAuditSection: React.FC = () => {
if (error) {
if (error === "enterprise-license-required") {
return (
<Alert color="blue" title={t("audit.enterpriseRequired", "Enterprise License Required")}>
<Alert
color="blue"
title={t("audit.enterpriseRequired", "Enterprise License Required")}
>
{t(
"audit.enterpriseRequiredMessage",
"The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.",
@@ -91,7 +117,10 @@ const AdminAuditSection: React.FC = () => {
);
}
return (
<Alert color="red" title={t("audit.error.title", "Error loading audit system")}>
<Alert
color="red"
title={t("audit.error.title", "Error loading audit system")}
>
{error}
</Alert>
);
@@ -99,8 +128,14 @@ const AdminAuditSection: React.FC = () => {
if (!systemStatus) {
return (
<Alert color="yellow" title={t("audit.notAvailable", "Audit system not available")}>
{t("audit.notAvailableMessage", "The audit system is not configured or not available.")}
<Alert
color="yellow"
title={t("audit.notAvailable", "Audit system not available")}
>
{t(
"audit.notAvailableMessage",
"The audit system is not configured or not available.",
)}
</Alert>
);
}
@@ -110,7 +145,10 @@ const AdminAuditSection: React.FC = () => {
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner show={!hasEnterpriseLicense} featureName={t("settings.licensingAnalytics.audit", "Audit")} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t("settings.licensingAnalytics.audit", "Audit")}
/>
{/* Info banner about audit settings */}
{isEnabled && (
@@ -131,7 +169,13 @@ const AdminAuditSection: React.FC = () => {
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("audit.goToSettings", "Go to Audit Settings")}
</Button>
@@ -161,14 +205,23 @@ const AdminAuditSection: React.FC = () => {
<Tabs.Panel value="dashboard" pt="md">
<Stack gap="lg">
{/* Stats Cards - Always Visible */}
<AuditStatsCards loginEnabled={isEnabled} timePeriod={timePeriod} />
<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.Control>
{t("audit.charts.overTime", "Events Over Time")}
</Accordion.Control>
<Accordion.Panel>
<AuditChartsSection loginEnabled={isEnabled} timePeriod={timePeriod} onTimePeriodChange={setTimePeriod} />
<AuditChartsSection
loginEnabled={isEnabled}
timePeriod={timePeriod}
onTimePeriodChange={setTimePeriod}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@@ -197,8 +250,14 @@ const AdminAuditSection: React.FC = () => {
</Tabs.Panel>
</Tabs>
) : (
<Alert color="blue" title={t("audit.disabled", "Audit logging is disabled")}>
{t("audit.disabledMessage", "Enable audit logging in your application configuration to track system events.")}
<Alert
color="blue"
title={t("audit.disabled", "Audit logging is disabled")}
>
{t(
"audit.disabledMessage",
"Enable audit logging in your application configuration to track system events.",
)}
</Alert>
)}
</Stack>
@@ -1,7 +1,19 @@
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 {
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";
@@ -12,7 +24,10 @@ 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 {
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";
@@ -136,45 +151,66 @@ export default function AdminConnectionsSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, closeRestartModal, restartServer } = useRestartServer();
const { restartModalOpened, closeRestartModal, restartServer } =
useRestartServer();
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: "connections",
fetchTransformer: async (): Promise<ConnectionsSettingsData & { _pending?: Record<string, unknown> }> => {
fetchTransformer: async (): Promise<
ConnectionsSettingsData & { _pending?: Record<string, unknown> }
> => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get("/api/v1/admin/settings/section/security");
const securityResponse = await apiClient.get(
"/api/v1/admin/settings/section/security",
);
const securityData = securityResponse.data || {};
// Fetch mail settings
const mailResponse = await apiClient.get("/api/v1/admin/settings/section/mail");
const mailResponse = await apiClient.get(
"/api/v1/admin/settings/section/mail",
);
const mailData = mailResponse.data || {};
// Fetch premium settings for SSO Auto Login
const premiumResponse = await apiClient.get("/api/v1/admin/settings/section/premium");
const premiumResponse = await apiClient.get(
"/api/v1/admin/settings/section/premium",
);
const premiumData = premiumResponse.data || {};
// Fetch Telegram settings
const telegramResponse = await apiClient.get("/api/v1/admin/settings/section/telegram");
const telegramResponse = await apiClient.get(
"/api/v1/admin/settings/section/telegram",
);
const telegramData = telegramResponse.data || {};
// Fetch system settings for enableMobileScanner
const systemResponse = await apiClient.get("/api/v1/admin/settings/section/system");
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: ConnectionsSettingsData & { _pending?: Record<string, unknown> } = {
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 || "",
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 || "",
};
@@ -194,34 +230,59 @@ export default function AdminConnectionsSection() {
pendingBlock.telegram = telegramData._pending;
}
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
pendingBlock.ssoAutoLogin = premiumData._pending.proFeatures.ssoAutoLogin;
pendingBlock.ssoAutoLogin =
premiumData._pending.proFeatures.ssoAutoLogin;
}
if (systemData._pending?.enableMobileScanner !== undefined) {
pendingBlock.enableMobileScanner = systemData._pending.enableMobileScanner;
pendingBlock.enableMobileScanner =
systemData._pending.enableMobileScanner;
}
if (systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined) {
pendingBlock.mobileScannerConvertToPdf = systemData._pending.mobileScannerSettings.convertToPdf;
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?.imageResolution !==
undefined
) {
pendingBlock.mobileScannerImageResolution =
systemData._pending.mobileScannerSettings.imageResolution;
}
if (systemData._pending?.mobileScannerSettings?.pageFormat !== undefined) {
pendingBlock.mobileScannerPageFormat = systemData._pending.mobileScannerSettings.pageFormat;
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 (
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?.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?.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?.apiKey !== undefined
) {
pendingBlock.googleDriveApiKey =
premiumData._pending.proFeatures.googleDrive.apiKey;
}
if (premiumData._pending?.proFeatures?.googleDrive?.appId !== undefined) {
pendingBlock.googleDriveAppId = premiumData._pending.proFeatures.googleDrive.appId;
pendingBlock.googleDriveAppId =
premiumData._pending.proFeatures.googleDrive.appId;
}
if (Object.keys(pendingBlock).length > 0) {
@@ -237,7 +298,9 @@ export default function AdminConnectionsSection() {
if (currentSettings.oauth2) {
Object.keys(currentSettings.oauth2).forEach((key) => {
if (key !== "client") {
deltaSettings[`security.oauth2.${key}`] = (currentSettings.oauth2 as Record<string, unknown>)[key];
deltaSettings[`security.oauth2.${key}`] = (
currentSettings.oauth2 as Record<string, unknown>
)[key];
}
});
@@ -245,9 +308,13 @@ export default function AdminConnectionsSection() {
const oauth2Client = currentSettings.oauth2.client;
if (oauth2Client) {
Object.keys(oauth2Client).forEach((providerId) => {
const providerSettings = oauth2Client[providerId] as Record<string, unknown>;
const providerSettings = oauth2Client[providerId] as Record<
string,
unknown
>;
Object.keys(providerSettings).forEach((key) => {
deltaSettings[`security.oauth2.client.${providerId}.${key}`] = providerSettings[key];
deltaSettings[`security.oauth2.client.${providerId}.${key}`] =
providerSettings[key];
});
});
}
@@ -279,38 +346,48 @@ export default function AdminConnectionsSection() {
// SSO Auto Login
if (currentSettings?.ssoAutoLogin !== undefined) {
deltaSettings["premium.proFeatures.ssoAutoLogin"] = currentSettings.ssoAutoLogin;
deltaSettings["premium.proFeatures.ssoAutoLogin"] =
currentSettings.ssoAutoLogin;
}
// Mobile Scanner settings
if (currentSettings?.enableMobileScanner !== undefined) {
deltaSettings["system.enableMobileScanner"] = currentSettings.enableMobileScanner;
deltaSettings["system.enableMobileScanner"] =
currentSettings.enableMobileScanner;
}
if (currentSettings?.mobileScannerConvertToPdf !== undefined) {
deltaSettings["system.mobileScannerSettings.convertToPdf"] = currentSettings.mobileScannerConvertToPdf;
deltaSettings["system.mobileScannerSettings.convertToPdf"] =
currentSettings.mobileScannerConvertToPdf;
}
if (currentSettings?.mobileScannerImageResolution !== undefined) {
deltaSettings["system.mobileScannerSettings.imageResolution"] = currentSettings.mobileScannerImageResolution;
deltaSettings["system.mobileScannerSettings.imageResolution"] =
currentSettings.mobileScannerImageResolution;
}
if (currentSettings?.mobileScannerPageFormat !== undefined) {
deltaSettings["system.mobileScannerSettings.pageFormat"] = currentSettings.mobileScannerPageFormat;
deltaSettings["system.mobileScannerSettings.pageFormat"] =
currentSettings.mobileScannerPageFormat;
}
if (currentSettings?.mobileScannerStretchToFit !== undefined) {
deltaSettings["system.mobileScannerSettings.stretchToFit"] = currentSettings.mobileScannerStretchToFit;
deltaSettings["system.mobileScannerSettings.stretchToFit"] =
currentSettings.mobileScannerStretchToFit;
}
// Google Drive settings
if (currentSettings?.googleDriveEnabled !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.enabled"] = currentSettings.googleDriveEnabled;
deltaSettings["premium.proFeatures.googleDrive.enabled"] =
currentSettings.googleDriveEnabled;
}
if (currentSettings?.googleDriveClientId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.clientId"] = currentSettings.googleDriveClientId;
deltaSettings["premium.proFeatures.googleDrive.clientId"] =
currentSettings.googleDriveClientId;
}
if (currentSettings?.googleDriveApiKey !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.apiKey"] = currentSettings.googleDriveApiKey;
deltaSettings["premium.proFeatures.googleDrive.apiKey"] =
currentSettings.googleDriveApiKey;
}
if (currentSettings?.googleDriveAppId !== undefined) {
deltaSettings["premium.proFeatures.googleDrive.appId"] = currentSettings.googleDriveAppId;
deltaSettings["premium.proFeatures.googleDrive.appId"] =
currentSettings.googleDriveAppId;
}
return {
@@ -320,7 +397,8 @@ export default function AdminConnectionsSection() {
},
});
const { settings, setSettings, loading, fetchSettings, isFieldPending } = adminSettings;
const { settings, setSettings, loading, fetchSettings, isFieldPending } =
adminSettings;
useEffect(() => {
if (loginEnabled) {
@@ -328,7 +406,10 @@ export default function AdminConnectionsSection() {
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
@@ -428,13 +509,21 @@ export default function AdminConnectionsSection() {
}
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
const availableProviders = allProviders.filter(
(p) => !isProviderConfigured(p),
);
const updateProviderSettings = (provider: Provider, updatedSettings: Record<string, unknown>) => {
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 });
setSettings({
...settings,
telegram: updatedSettings as TelegramSettingsData,
});
} else if (provider.id === "googledrive") {
const gd = updatedSettings as GoogleDriveSettings;
setSettings({
@@ -476,7 +565,10 @@ export default function AdminConnectionsSection() {
{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.")}
{t(
"admin.settings.connections.description",
"Configure external authentication providers like OAuth2 and SAML.",
)}
</Text>
</div>
@@ -485,23 +577,38 @@ export default function AdminConnectionsSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t("admin.settings.connections.ssoAutoLogin.label", "SSO Auto Login")}
{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")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
</Group>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<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")}
{t(
"admin.settings.connections.ssoAutoLogin.enable",
"Enable SSO Auto Login",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -515,7 +622,10 @@ export default function AdminConnectionsSection() {
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
setSettings({
...settings,
ssoAutoLogin: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -530,21 +640,46 @@ export default function AdminConnectionsSection() {
<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" />
<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")}
{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
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
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.connections.mobileScanner.enable", "Enable QR Code Upload")}
{t(
"admin.settings.connections.mobileScanner.enable",
"Enable QR Code Upload",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -553,7 +688,10 @@ export default function AdminConnectionsSection() {
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t("admin.settings.connections.mobileScanner.note", "Note: Requires Frontend URL to be configured. ")}
{t(
"admin.settings.connections.mobileScanner.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
@@ -563,7 +701,10 @@ export default function AdminConnectionsSection() {
c="orange"
td="underline"
>
{t("admin.settings.connections.mobileScanner.link", "Configure in System Settings")}
{t(
"admin.settings.connections.mobileScanner.link",
"Configure in System Settings",
)}
</Anchor>
</Text>
</div>
@@ -572,7 +713,10 @@ export default function AdminConnectionsSection() {
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, enableMobileScanner: e.target.checked });
setSettings({
...settings,
enableMobileScanner: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -587,12 +731,18 @@ export default function AdminConnectionsSection() {
gap="md"
mt="md"
ml="lg"
style={{ borderLeft: "2px solid var(--mantine-color-gray-3)", paddingLeft: "1rem" }}
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")}
{t(
"admin.settings.connections.mobileScannerConvertToPdf",
"Convert Images to PDF",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
@@ -605,11 +755,16 @@ export default function AdminConnectionsSection() {
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
setSettings({
...settings,
mobileScannerConvertToPdf: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("mobileScannerConvertToPdf")} />
<PendingBadge
show={isFieldPending("mobileScannerConvertToPdf")}
/>
</Group>
</div>
@@ -619,7 +774,10 @@ export default function AdminConnectionsSection() {
{/* Image Resolution */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerImageResolution", "Image Resolution")}
{t(
"admin.settings.connections.mobileScannerImageResolution",
"Image Resolution",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
@@ -629,33 +787,49 @@ export default function AdminConnectionsSection() {
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerImageResolution || "full"}
value={
settings?.mobileScannerImageResolution || "full"
}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerImageResolution: value || "full" });
setSettings({
...settings,
mobileScannerImageResolution: value || "full",
});
}}
data={[
{
value: "full",
label: t("admin.settings.connections.imageResolutionFull", "Full (Original Size)"),
label: t(
"admin.settings.connections.imageResolutionFull",
"Full (Original Size)",
),
},
{
value: "reduced",
label: t("admin.settings.connections.imageResolutionReduced", "Reduced (Max 1200px)"),
label: t(
"admin.settings.connections.imageResolutionReduced",
"Reduced (Max 1200px)",
),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge show={isFieldPending("mobileScannerImageResolution")} />
<PendingBadge
show={isFieldPending("mobileScannerImageResolution")}
/>
</Group>
</div>
{/* Page Format */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerPageFormat", "Page Format")}
{t(
"admin.settings.connections.mobileScannerPageFormat",
"Page Format",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
@@ -668,28 +842,51 @@ export default function AdminConnectionsSection() {
value={settings?.mobileScannerPageFormat || "A4"}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerPageFormat: value || "A4" });
setSettings({
...settings,
mobileScannerPageFormat: value || "A4",
});
}}
data={[
{
value: "keep",
label: t("admin.settings.connections.pageFormatKeep", "Keep (Original Dimensions)"),
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)",
),
},
{ 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")} />
<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")}
{t(
"admin.settings.connections.mobileScannerStretchToFit",
"Stretch to Fit",
)}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
@@ -702,11 +899,16 @@ export default function AdminConnectionsSection() {
checked={settings?.mobileScannerStretchToFit || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked });
setSettings({
...settings,
mobileScannerStretchToFit: e.target.checked,
});
}}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("mobileScannerStretchToFit")} />
<PendingBadge
show={isFieldPending("mobileScannerStretchToFit")}
/>
</Group>
</div>
</>
@@ -721,7 +923,10 @@ export default function AdminConnectionsSection() {
<>
<div>
<Text fw={600} size="md" mb="md">
{t("admin.settings.connections.linkedServices", "Linked Services")}
{t(
"admin.settings.connections.linkedServices",
"Linked Services",
)}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
@@ -730,7 +935,9 @@ export default function AdminConnectionsSection() {
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
@@ -746,7 +953,10 @@ export default function AdminConnectionsSection() {
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t("admin.settings.connections.unlinkedServices", "Unlinked Services")}
{t(
"admin.settings.connections.unlinkedServices",
"Unlinked Services",
)}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
@@ -755,7 +965,9 @@ export default function AdminConnectionsSection() {
provider={provider}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
onChange={(updatedSettings) =>
updateProviderSettings(provider, updatedSettings)
}
disabled={!loginEnabled}
/>
))}
@@ -764,7 +976,11 @@ export default function AdminConnectionsSection() {
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
<SettingsStickyFooter
@@ -34,7 +34,9 @@ import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBann
import EditableSecretField from "@app/components/shared/EditableSecretField";
import apiClient from "@app/services/apiClient";
import LocalIcon from "@app/components/shared/LocalIcon";
import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService";
import databaseManagementService, {
DatabaseBackupFile,
} from "@app/services/databaseManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface DatabaseSettingsData {
@@ -50,68 +52,91 @@ interface DatabaseSettingsData {
export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const [backupFiles, setBackupFiles] = useState<DatabaseBackupFile[]>([]);
const [databaseVersion, setDatabaseVersion] = useState<string | null>(null);
const [backupsLoading, setBackupsLoading] = useState(false);
const [creatingBackup, setCreatingBackup] = useState(false);
const [importingUpload, setImportingUpload] = useState(false);
const [importingBackupFile, setImportingBackupFile] = useState<string | null>(null);
const [importingBackupFile, setImportingBackupFile] = useState<string | null>(
null,
);
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [confirmImportOpen, setConfirmImportOpen] = useState(false);
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(
null,
);
const [confirmCode, setConfirmCode] = useState("");
const [confirmInput, setConfirmInput] = useState("");
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<DatabaseSettingsData>({
sectionName: "database",
fetchTransformer: async (): Promise<DatabaseSettingsData & { _pending?: Record<string, unknown> }> => {
const response = await apiClient.get("/api/v1/admin/settings/section/system");
const systemData = response.data || {};
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<DatabaseSettingsData>({
sectionName: "database",
fetchTransformer: async (): Promise<
DatabaseSettingsData & { _pending?: Record<string, unknown> }
> => {
const response = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = response.data || {};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: "",
username: "",
password: "",
type: "postgresql",
hostName: "localhost",
port: 5432,
name: "postgres",
};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: "",
username: "",
password: "",
type: "postgresql",
hostName: "localhost",
port: 5432,
name: "postgres",
};
// Map pending changes from system._pending.datasource to root level
const result: DatabaseSettingsData & { _pending?: Record<string, unknown> } = { ...datasource };
if (systemData._pending?.datasource) {
result._pending = systemData._pending.datasource;
}
// Map pending changes from system._pending.datasource to root level
const result: DatabaseSettingsData & {
_pending?: Record<string, unknown>;
} = { ...datasource };
if (systemData._pending?.datasource) {
result._pending = systemData._pending.datasource;
}
return result;
},
saveTransformer: (settings: DatabaseSettingsData) => {
// Convert flat settings to dot-notation for delta endpoint
const deltaSettings: Record<string, unknown> = {
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
"system.datasource.customDatabaseUrl": settings.customDatabaseUrl,
"system.datasource.username": settings.username,
"system.datasource.password": settings.password,
"system.datasource.type": settings.type,
"system.datasource.hostName": settings.hostName,
"system.datasource.port": settings.port,
"system.datasource.name": settings.name,
};
return result;
},
saveTransformer: (settings: DatabaseSettingsData) => {
// Convert flat settings to dot-notation for delta endpoint
const deltaSettings: Record<string, unknown> = {
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
"system.datasource.customDatabaseUrl": settings.customDatabaseUrl,
"system.datasource.username": settings.username,
"system.datasource.password": settings.password,
"system.datasource.type": settings.type,
"system.datasource.hostName": settings.hostName,
"system.datasource.port": settings.port,
"system.datasource.name": settings.name,
};
return {
sectionData: {},
deltaSettings,
};
},
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -143,10 +168,15 @@ export default function AdminDatabaseSection() {
setBackupFiles(data.backupFiles || []);
setDatabaseVersion(data.databaseVersion || null);
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.loadError", "Failed to load database backups"),
title: t(
"admin.settings.database.loadError",
"Failed to load database backups",
),
body: message,
});
} finally {
@@ -158,7 +188,10 @@ export default function AdminDatabaseSection() {
loadBackupData();
}, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -188,13 +221,24 @@ export default function AdminDatabaseSection() {
setCreatingBackup(true);
try {
await databaseManagementService.createBackup();
alert({ alertType: "success", title: t("admin.settings.database.backupCreated", "Backup created successfully") });
alert({
alertType: "success",
title: t(
"admin.settings.database.backupCreated",
"Backup created successfully",
),
});
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.backupFailed", "Failed to create backup"),
title: t(
"admin.settings.database.backupFailed",
"Failed to create backup",
),
body: message,
});
} finally {
@@ -207,14 +251,25 @@ export default function AdminDatabaseSection() {
setImportingUpload(true);
try {
await databaseManagementService.uploadAndImport(uploadFile);
alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") });
alert({
alertType: "success",
title: t(
"admin.settings.database.importSuccess",
"Backup imported successfully",
),
});
setUploadFile(null);
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
title: t(
"admin.settings.database.importFailed",
"Failed to import backup",
),
body: message,
});
} finally {
@@ -223,7 +278,10 @@ export default function AdminDatabaseSection() {
};
const generateConfirmationCode = () => {
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
if (
typeof crypto !== "undefined" &&
typeof crypto.getRandomValues === "function"
) {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
const randomNumber = array[0] % 10000; // 0-9999
@@ -237,7 +295,13 @@ export default function AdminDatabaseSection() {
const handleUploadImport = () => {
if (!validateLoginEnabled()) return;
if (!uploadFile) {
alert({ alertType: "warning", title: t("admin.settings.database.selectFile", "Please select a .sql file to import") });
alert({
alertType: "warning",
title: t(
"admin.settings.database.selectFile",
"Please select a .sql file to import",
),
});
return;
}
const code = generateConfirmationCode();
@@ -255,8 +319,14 @@ export default function AdminDatabaseSection() {
if (confirmInput !== confirmCode) {
alert({
alertType: "warning",
title: t("admin.settings.database.codeMismatch", "Confirmation code does not match"),
body: t("admin.settings.database.codeMismatchBody", "Please enter the code exactly as shown to proceed."),
title: t(
"admin.settings.database.codeMismatch",
"Confirmation code does not match",
),
body: t(
"admin.settings.database.codeMismatchBody",
"Please enter the code exactly as shown to proceed.",
),
});
return;
}
@@ -269,13 +339,24 @@ export default function AdminDatabaseSection() {
setImportingBackupFile(fileName);
try {
await databaseManagementService.importFromFileName(fileName);
alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") });
alert({
alertType: "success",
title: t(
"admin.settings.database.importSuccess",
"Backup imported successfully",
),
});
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
title: t(
"admin.settings.database.importFailed",
"Failed to import backup",
),
body: message,
});
} finally {
@@ -288,13 +369,21 @@ export default function AdminDatabaseSection() {
setDeletingFile(fileName);
try {
await databaseManagementService.deleteBackup(fileName);
alert({ alertType: "success", title: t("admin.settings.database.deleteSuccess", "Backup deleted") });
alert({
alertType: "success",
title: t("admin.settings.database.deleteSuccess", "Backup deleted"),
});
await loadBackupData();
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.deleteFailed", "Failed to delete backup"),
title: t(
"admin.settings.database.deleteFailed",
"Failed to delete backup",
),
body: message,
});
} finally {
@@ -322,10 +411,15 @@ export default function AdminDatabaseSection() {
document.body.appendChild(link);
link.click();
} catch (error: unknown) {
const message = isAxiosError(error) ? error.response?.data?.message || error.message : undefined;
const message = isAxiosError(error)
? error.response?.data?.message || error.message
: undefined;
alert({
alertType: "error",
title: t("admin.settings.database.downloadFailed", "Failed to download backup"),
title: t(
"admin.settings.database.downloadFailed",
"Failed to download backup",
),
body: message,
});
} finally {
@@ -378,13 +472,25 @@ export default function AdminDatabaseSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
{t(
"admin.settings.database.configuration",
"Database Configuration",
)}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
{t(
"admin.settings.database.enableCustom.label",
"Enable Custom Database",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -398,7 +504,10 @@ export default function AdminDatabaseSection() {
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
setSettings({
...settings,
enableCustomDatabase: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -413,8 +522,15 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
<span>
{t(
"admin.settings.database.customUrl.label",
"Custom Database URL",
)}
</span>
<PendingBadge
show={isFieldPending("customDatabaseUrl")}
/>
</Group>
}
description={t(
@@ -422,7 +538,12 @@ export default function AdminDatabaseSection() {
"Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
)}
value={settings?.customDatabaseUrl || ""}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
onChange={(e) =>
setSettings({
...settings,
customDatabaseUrl: e.target.value,
})
}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
@@ -432,7 +553,12 @@ export default function AdminDatabaseSection() {
<Select
label={
<Group gap="xs">
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<span>
{t(
"admin.settings.database.type.label",
"Database Type",
)}
</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
@@ -441,7 +567,9 @@ export default function AdminDatabaseSection() {
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
onChange={(value) =>
setSettings({ ...settings, type: value || "postgresql" })
}
data={[
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
@@ -456,7 +584,12 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<span>
{t(
"admin.settings.database.hostName.label",
"Host Name",
)}
</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
@@ -465,7 +598,9 @@ export default function AdminDatabaseSection() {
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, hostName: e.target.value })
}
placeholder="localhost"
disabled={!loginEnabled}
/>
@@ -475,7 +610,9 @@ export default function AdminDatabaseSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.port.label", "Port")}</span>
<span>
{t("admin.settings.database.port.label", "Port")}
</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
@@ -484,7 +621,9 @@ export default function AdminDatabaseSection() {
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
onChange={(value) =>
setSettings({ ...settings, port: Number(value) })
}
min={1}
max={65535}
disabled={!loginEnabled}
@@ -495,7 +634,12 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<span>
{t(
"admin.settings.database.name.label",
"Database Name",
)}
</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
@@ -504,7 +648,9 @@ export default function AdminDatabaseSection() {
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, name: e.target.value })
}
placeholder="postgres"
disabled={!loginEnabled}
/>
@@ -514,13 +660,23 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.username.label", "Username")}</span>
<span>
{t(
"admin.settings.database.username.label",
"Username",
)}
</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.database.username.description", "Database authentication username")}
description={t(
"admin.settings.database.username.description",
"Database authentication username",
)}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, username: e.target.value })
}
placeholder="postgres"
disabled={!loginEnabled}
/>
@@ -534,9 +690,14 @@ export default function AdminDatabaseSection() {
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
description={t(
"admin.settings.database.password.description",
"Database authentication password",
)}
value={settings?.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
@@ -555,7 +716,11 @@ export default function AdminDatabaseSection() {
onDiscard={handleDiscard}
/>
<Stack gap="lg" className="settings-section-content" style={{ marginTop: 0 }}>
<Stack
gap="lg"
className="settings-section-content"
style={{ marginTop: 0 }}
>
<Divider my="md" />
<Stack gap="md">
@@ -565,13 +730,17 @@ export default function AdminDatabaseSection() {
{t("admin.settings.database.backupTitle", "Backups & Restore")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.database.backupDescription", "Manage H2 backups directly from the admin console.")}
{t(
"admin.settings.database.backupDescription",
"Manage H2 backups directly from the admin console.",
)}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
{t("admin.settings.database.version", "H2 Version")}:{" "}
{databaseVersion}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
@@ -583,9 +752,16 @@ export default function AdminDatabaseSection() {
</Group>
{!isEmbeddedH2 && (
<Alert icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />} color="yellow" radius="md">
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
color="yellow"
radius="md"
>
<Text fw={600} size="sm">
{t("admin.settings.database.h2Only", "Backups are available only for the embedded H2 database.")}
{t(
"admin.settings.database.h2Only",
"Backups are available only for the embedded H2 database.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
@@ -601,37 +777,59 @@ export default function AdminDatabaseSection() {
<Group justify="space-between" align="center">
<Group gap="xs">
<LocalIcon icon="backup" width="1.4rem" height="1.4rem" />
<Text fw={600}>{t("admin.settings.database.manageBackups", "Manage backups")}</Text>
<Text fw={600}>
{t(
"admin.settings.database.manageBackups",
"Manage backups",
)}
</Text>
</Group>
<Group gap="xs">
<Button
variant="light"
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="refresh" width="1rem" height="1rem" />
}
onClick={loadBackupData}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.refresh", "Refresh")}
</Button>
<Button
leftSection={<LocalIcon icon="cloud-upload" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="cloud-upload"
width="1rem"
height="1rem"
/>
}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.createBackup", "Create backup")}
{t(
"admin.settings.database.createBackup",
"Create backup",
)}
</Button>
</Group>
</Group>
<Box>
<Text fw={500} size="sm" mb={6}>
{t("admin.settings.database.uploadTitle", "Upload & import")}
{t(
"admin.settings.database.uploadTitle",
"Upload & import",
)}
</Text>
<Group gap="sm" align="flex-end" wrap="wrap">
<FileInput
value={uploadFile}
onChange={setUploadFile}
placeholder={t("admin.settings.database.chooseFile", "Choose a .sql backup file")}
placeholder={t(
"admin.settings.database.chooseFile",
"Choose a .sql backup file",
)}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
@@ -641,9 +839,18 @@ export default function AdminDatabaseSection() {
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="play-circle"
width="1rem"
height="1rem"
/>
}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
{t(
"admin.settings.database.importFromUpload",
"Import upload",
)}
</Button>
</Group>
</Box>
@@ -655,67 +862,122 @@ export default function AdminDatabaseSection() {
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? t("admin.settings.database.noBackups", "No backups found yet.")
? t(
"admin.settings.database.noBackups",
"No backups found yet.",
)
: t(
"admin.settings.database.unavailable",
"Backup list unavailable for the current database configuration.",
)}
</Text>
) : (
<Table highlightOnHover withColumnBorders verticalSpacing="sm">
<Table
highlightOnHover
withColumnBorders
verticalSpacing="sm"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("admin.settings.database.fileName", "File")}</Table.Th>
<Table.Th>{t("admin.settings.database.created", "Created")}</Table.Th>
<Table.Th>{t("admin.settings.database.size", "Size")}</Table.Th>
<Table.Th w={150}>{t("admin.settings.database.actions", "Actions")}</Table.Th>
<Table.Th>
{t("admin.settings.database.fileName", "File")}
</Table.Th>
<Table.Th>
{t("admin.settings.database.created", "Created")}
</Table.Th>
<Table.Th>
{t("admin.settings.database.size", "Size")}
</Table.Th>
<Table.Th w={150}>
{t("admin.settings.database.actions", "Actions")}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{backupFiles.map((backup) => (
<Table.Tr key={backup.fileName}>
<Table.Td>{backup.fileName}</Table.Td>
<Table.Td>{backup.formattedCreationDate || backup.creationDate || "-"}</Table.Td>
<Table.Td>
{backup.formattedCreationDate ||
backup.creationDate ||
"-"}
</Table.Td>
<Table.Td>{backup.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<Tooltip
label={t(
"admin.settings.database.download",
"Download",
)}
withArrow
>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
onClick={() =>
handleDownload(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
<LocalIcon
icon="download"
width="1rem"
height="1rem"
/>
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<Tooltip
label={t(
"admin.settings.database.import",
"Import",
)}
withArrow
>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
onClick={() =>
handleImportExisting(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
<LocalIcon
icon="backup"
width="1rem"
height="1rem"
/>
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<Tooltip
label={t(
"admin.settings.database.delete",
"Delete",
)}
withArrow
>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
onClick={() =>
handleDeleteClick(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
<LocalIcon
icon="delete"
width="1rem"
height="1rem"
/>
)}
</ActionIcon>
</Tooltip>
@@ -732,20 +994,34 @@ export default function AdminDatabaseSection() {
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
title={t("admin.settings.database.confirmImportTitle", "Confirm database import")}
title={t(
"admin.settings.database.confirmImportTitle",
"Confirm database import",
)}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Alert
color="red"
variant="light"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
{t(
"admin.settings.database.overwriteWarning",
"Warning: This will overwrite the current database.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
@@ -756,7 +1032,10 @@ export default function AdminDatabaseSection() {
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
{t(
"admin.settings.database.confirmCodeLabel",
"Enter the confirmation code to proceed",
)}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
@@ -764,17 +1043,29 @@ export default function AdminDatabaseSection() {
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
placeholder={t(
"admin.settings.database.enterCode",
"Enter the code shown above",
)}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
<Button
variant="default"
onClick={closeConfirmImportModal}
disabled={importingUpload}
>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
<Button
color="red"
onClick={handleConfirmImport}
loading={importingUpload}
disabled={confirmInput.length === 0}
>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
@@ -790,22 +1081,40 @@ export default function AdminDatabaseSection() {
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>{t("admin.settings.database.deleteConfirm", "Delete this backup? This cannot be undone.")}</Text>
<Alert
color="red"
variant="light"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
>
<Text fw={600}>
{t(
"admin.settings.database.deleteConfirm",
"Delete this backup? This cannot be undone.",
)}
</Text>
<Text size="sm" c="dimmed">
{deleteConfirmFile}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteConfirmFile(null)} disabled={deletingFile !== null}>
<Button
variant="default"
onClick={() => setDeleteConfirmFile(null)}
disabled={deletingFile !== null}
>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
onClick={() =>
deleteConfirmFile && handleDelete(deleteConfirmFile)
}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
{t(
"admin.settings.database.deleteConfirmAction",
"Delete backup",
)}
</Button>
</Group>
</Stack>
@@ -1,6 +1,14 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from "@mantine/core";
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";
@@ -24,12 +32,24 @@ interface EndpointsSettingsData {
export default function AdminEndpointsSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<EndpointsSettingsData>({
sectionName: "endpoints",
});
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<EndpointsSettingsData>({
sectionName: "endpoints",
});
const {
settings: uiSettings,
@@ -96,7 +116,14 @@ export default function AdminEndpointsSection() {
const original = resetUiSnapshot();
setUiSettings(original);
}
}, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]);
}, [
isEndpointsDirty,
isUiDirty,
resetEndpointsSnapshot,
resetUiSnapshot,
setSettings,
setUiSettings,
]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading || uiLoading : false;
@@ -228,7 +255,10 @@ export default function AdminEndpointsSection() {
{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.")}
{t(
"admin.settings.endpoints.description",
"Control which API endpoints and endpoint groups are available.",
)}
</Text>
</div>
@@ -242,17 +272,28 @@ export default function AdminEndpointsSection() {
<MultiSelect
label={
<Group gap="xs">
<span>{t("admin.settings.endpoints.toRemove.label", "Disabled Endpoints")}</span>
<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")}
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 }))}
data={commonEndpoints.map((endpoint) => ({
value: endpoint,
label: endpoint,
}))}
searchable
clearable
placeholder="Select endpoints to disable"
@@ -265,17 +306,28 @@ export default function AdminEndpointsSection() {
<MultiSelect
label={
<Group gap="xs">
<span>{t("admin.settings.endpoints.groupsToRemove.label", "Disabled Endpoint Groups")}</span>
<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")}
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 }))}
data={commonGroups.map((group) => ({
value: group,
label: group,
}))}
searchable
clearable
placeholder="Select groups to disable"
@@ -290,7 +342,10 @@ export default function AdminEndpointsSection() {
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.endpoints.userDefaults", "User Preference Defaults")}
{t(
"admin.settings.endpoints.userDefaults",
"User Preference Defaults",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
@@ -304,9 +359,14 @@ export default function AdminEndpointsSection() {
label={
<Group gap="xs">
<span>
{t("admin.settings.endpoints.defaultHideUnavailableTools.label", "Hide unavailable tools by default")}
{t(
"admin.settings.endpoints.defaultHideUnavailableTools.label",
"Hide unavailable tools by default",
)}
</span>
<PendingBadge show={isUiFieldPending("defaultHideUnavailableTools")} />
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableTools")}
/>
</Group>
}
description={t(
@@ -316,7 +376,10 @@ export default function AdminEndpointsSection() {
checked={uiSettings.defaultHideUnavailableTools || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableTools: e.currentTarget.checked });
setUiSettings({
...uiSettings,
defaultHideUnavailableTools: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
@@ -330,7 +393,9 @@ export default function AdminEndpointsSection() {
"Hide unavailable conversions by default",
)}
</span>
<PendingBadge show={isUiFieldPending("defaultHideUnavailableConversions")} />
<PendingBadge
show={isUiFieldPending("defaultHideUnavailableConversions")}
/>
</Group>
}
description={t(
@@ -340,7 +405,10 @@ export default function AdminEndpointsSection() {
checked={uiSettings.defaultHideUnavailableConversions || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableConversions: e.currentTarget.checked });
setUiSettings({
...uiSettings,
defaultHideUnavailableConversions: e.currentTarget.checked,
});
}}
disabled={!loginEnabled}
/>
@@ -357,7 +425,11 @@ export default function AdminEndpointsSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -1,7 +1,17 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Badge } from "@mantine/core";
import {
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";
@@ -25,48 +35,73 @@ interface FeaturesSettingsData {
export default function AdminFeaturesSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<FeaturesSettingsData>({
sectionName: "features",
fetchTransformer: async (): Promise<FeaturesSettingsData & { _pending?: Record<string, unknown> }> => {
const systemResponse = await apiClient.get("/api/v1/admin/settings/section/system");
const systemData = systemResponse.data || {};
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<FeaturesSettingsData>({
sectionName: "features",
fetchTransformer: async (): Promise<
FeaturesSettingsData & { _pending?: Record<string, unknown> }
> => {
const systemResponse = await apiClient.get(
"/api/v1/admin/settings/section/system",
);
const systemData = systemResponse.data || {};
const result: FeaturesSettingsData & { _pending?: Record<string, unknown> } = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: "Stirling-PDF",
validity: 365,
regenerateOnStartup: false,
},
const result: FeaturesSettingsData & {
_pending?: Record<string, unknown>;
} = {
serverCertificate: systemData.serverCertificate || {
enabled: true,
organizationName: "Stirling-PDF",
validity: 365,
regenerateOnStartup: false,
},
};
// Map pending changes from system._pending.serverCertificate
if (systemData._pending?.serverCertificate) {
result._pending = {
serverCertificate: systemData._pending.serverCertificate,
};
}
// Map pending changes from system._pending.serverCertificate
if (systemData._pending?.serverCertificate) {
result._pending = { serverCertificate: systemData._pending.serverCertificate };
}
return result;
},
saveTransformer: (settings: FeaturesSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
return result;
},
saveTransformer: (settings: FeaturesSettingsData) => {
const deltaSettings: Record<string, unknown> = {};
if (settings.serverCertificate) {
deltaSettings["system.serverCertificate.enabled"] =
settings.serverCertificate.enabled;
deltaSettings["system.serverCertificate.organizationName"] =
settings.serverCertificate.organizationName;
deltaSettings["system.serverCertificate.validity"] =
settings.serverCertificate.validity;
deltaSettings["system.serverCertificate.regenerateOnStartup"] =
settings.serverCertificate.regenerateOnStartup;
}
if (settings.serverCertificate) {
deltaSettings["system.serverCertificate.enabled"] = settings.serverCertificate.enabled;
deltaSettings["system.serverCertificate.organizationName"] = settings.serverCertificate.organizationName;
deltaSettings["system.serverCertificate.validity"] = settings.serverCertificate.validity;
deltaSettings["system.serverCertificate.regenerateOnStartup"] = settings.serverCertificate.regenerateOnStartup;
}
return {
sectionData: {},
deltaSettings,
};
},
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -74,7 +109,10 @@ export default function AdminFeaturesSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -117,7 +155,10 @@ export default function AdminFeaturesSection() {
{t("admin.settings.features.title", "Features")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.features.description", "Configure optional features and functionality.")}
{t(
"admin.settings.features.description",
"Configure optional features and functionality.",
)}
</Text>
</div>
@@ -126,14 +167,20 @@ export default function AdminFeaturesSection() {
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t("admin.settings.features.serverCertificate.label", "Server Certificate")}
{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")}
title={t(
"admin.settings.badge.clickToUpgrade",
"Click to view plan details",
)}
>
PRO
</Badge>
@@ -146,10 +193,19 @@ export default function AdminFeaturesSection() {
)}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<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")}
{t(
"admin.settings.features.serverCertificate.enabled.label",
"Enable Server Certificate",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -165,13 +221,18 @@ export default function AdminFeaturesSection() {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked },
serverCertificate: {
...settings.serverCertificate,
enabled: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("serverCertificate.enabled")} />
<PendingBadge
show={isFieldPending("serverCertificate.enabled")}
/>
</Group>
</div>
@@ -179,19 +240,33 @@ export default function AdminFeaturesSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.features.serverCertificate.organizationName.label", "Organization Name")}</span>
<PendingBadge show={isFieldPending("serverCertificate.organizationName")} />
<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"}
value={
settings.serverCertificate?.organizationName || "Stirling-PDF"
}
onChange={(e) =>
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value },
serverCertificate: {
...settings.serverCertificate,
organizationName: e.target.value,
},
})
}
placeholder="Stirling-PDF"
@@ -203,8 +278,15 @@ export default function AdminFeaturesSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.features.serverCertificate.validity.label", "Certificate Validity (days)")}</span>
<PendingBadge show={isFieldPending("serverCertificate.validity")} />
<span>
{t(
"admin.settings.features.serverCertificate.validity.label",
"Certificate Validity (days)",
)}
</span>
<PendingBadge
show={isFieldPending("serverCertificate.validity")}
/>
</Group>
}
description={t(
@@ -215,7 +297,10 @@ export default function AdminFeaturesSection() {
onChange={(value) =>
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, validity: Number(value) },
serverCertificate: {
...settings.serverCertificate,
validity: Number(value),
},
})
}
min={1}
@@ -224,10 +309,19 @@ export default function AdminFeaturesSection() {
/>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<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")}
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
"Regenerate on Startup",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -238,18 +332,25 @@ export default function AdminFeaturesSection() {
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
checked={
settings.serverCertificate?.regenerateOnStartup ?? false
}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked },
serverCertificate: {
...settings.serverCertificate,
regenerateOnStartup: e.target.checked,
},
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("serverCertificate.regenerateOnStartup")} />
<PendingBadge
show={isFieldPending("serverCertificate.regenerateOnStartup")}
/>
</Group>
</div>
</Stack>
@@ -265,7 +366,11 @@ export default function AdminFeaturesSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -1,6 +1,14 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TextInput, Stack, Paper, Text, Loader, Group, Alert } from "@mantine/core";
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";
@@ -23,12 +31,24 @@ interface LegalSettingsData {
export default function AdminLegalSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<LegalSettingsData>({
sectionName: "legal",
});
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<LegalSettingsData>({
sectionName: "legal",
});
useEffect(() => {
if (loginEnabled) {
@@ -36,7 +56,10 @@ export default function AdminLegalSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
@@ -78,14 +101,20 @@ export default function AdminLegalSection() {
{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.")}
{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")}
title={t(
"admin.settings.legal.disclaimer.title",
"Legal Responsibility Warning",
)}
color="yellow"
variant="light"
>
@@ -103,7 +132,12 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.termsAndConditions.label", "Terms and Conditions")}</span>
<span>
{t(
"admin.settings.legal.termsAndConditions.label",
"Terms and Conditions",
)}
</span>
<PendingBadge show={isFieldPending("termsAndConditions")} />
</Group>
}
@@ -112,7 +146,12 @@ export default function AdminLegalSection() {
"URL or filename to terms and conditions",
)}
value={settings.termsAndConditions || ""}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
onChange={(e) =>
setSettings({
...settings,
termsAndConditions: e.target.value,
})
}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
@@ -122,13 +161,23 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.privacyPolicy.label", "Privacy Policy")}</span>
<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")}
description={t(
"admin.settings.legal.privacyPolicy.description",
"URL or filename to privacy policy",
)}
value={settings.privacyPolicy || ""}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, privacyPolicy: e.target.value })
}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
@@ -138,8 +187,15 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.accessibilityStatement.label", "Accessibility Statement")}</span>
<PendingBadge show={isFieldPending("accessibilityStatement")} />
<span>
{t(
"admin.settings.legal.accessibilityStatement.label",
"Accessibility Statement",
)}
</span>
<PendingBadge
show={isFieldPending("accessibilityStatement")}
/>
</Group>
}
description={t(
@@ -147,7 +203,12 @@ export default function AdminLegalSection() {
"URL or filename to accessibility statement",
)}
value={settings.accessibilityStatement || ""}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
onChange={(e) =>
setSettings({
...settings,
accessibilityStatement: e.target.value,
})
}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
@@ -157,13 +218,23 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.cookiePolicy.label", "Cookie Policy")}</span>
<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")}
description={t(
"admin.settings.legal.cookiePolicy.description",
"URL or filename to cookie policy",
)}
value={settings.cookiePolicy || ""}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, cookiePolicy: e.target.value })
}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
@@ -173,7 +244,9 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.impressum.label", "Impressum")}</span>
<span>
{t("admin.settings.legal.impressum.label", "Impressum")}
</span>
<PendingBadge show={isFieldPending("impressum")} />
</Group>
}
@@ -182,7 +255,9 @@ export default function AdminLegalSection() {
"URL or filename to impressum (required in some jurisdictions)",
)}
value={settings.impressum || ""}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, impressum: e.target.value })
}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
@@ -200,7 +275,11 @@ export default function AdminLegalSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -1,7 +1,17 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Anchor } from "@mantine/core";
import {
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";
@@ -28,34 +38,54 @@ interface ApiResponseWithPending<T> {
_pending?: Partial<T>;
}
type MailApiResponse = MailSettingsData & ApiResponseWithPending<MailSettingsData>;
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 {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<MailSettingsData>({
sectionName: "mail",
fetchTransformer: async (): Promise<MailSettingsData & { _pending?: Partial<MailSettingsData> }> => {
const mailResponse = await apiClient.get<MailApiResponse>("/api/v1/admin/settings/section/mail");
return mailResponse.data || {};
},
saveTransformer: (settings: MailSettingsData) => {
return {
sectionData: settings,
deltaSettings: {},
};
},
});
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: "mail",
fetchTransformer: async (): Promise<
MailSettingsData & { _pending?: Partial<MailSettingsData> }
> => {
const mailResponse = await apiClient.get<MailApiResponse>(
"/api/v1/admin/settings/section/mail",
);
return mailResponse.data || {};
},
saveTransformer: (settings: MailSettingsData) => {
return {
sectionData: settings,
deltaSettings: {},
};
},
});
useEffect(() => {
fetchSettings();
}, []);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
@@ -92,7 +122,10 @@ export default function AdminMailSection() {
{t("admin.settings.mail.title", "Mail Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.mail.description", "Configure SMTP settings for email notifications.")}
{t(
"admin.settings.mail.description",
"Configure SMTP settings for email notifications.",
)}
</Text>
</div>
@@ -104,13 +137,18 @@ export default function AdminMailSection() {
{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")}
{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 })}
onChange={(e) =>
setSettings({ ...settings, enabled: e.target.checked })
}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
@@ -119,7 +157,10 @@ export default function AdminMailSection() {
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mail.enableInvites.label", "Enable Email Invites")}
{t(
"admin.settings.mail.enableInvites.label",
"Enable Email Invites",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -128,7 +169,10 @@ export default function AdminMailSection() {
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t("admin.settings.mail.frontendUrlNote.note", "Note: Requires Frontend URL to be configured. ")}
{t(
"admin.settings.mail.frontendUrlNote.note",
"Note: Requires Frontend URL to be configured. ",
)}
<Anchor
href="#"
onClick={(e) => {
@@ -138,14 +182,22 @@ export default function AdminMailSection() {
c="orange"
td="underline"
>
{t("admin.settings.mail.frontendUrlNote.link", "Configure in System Settings")}
{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 })}
onChange={(e) =>
setSettings({
...settings,
enableInvites: e.target.checked,
})
}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending("enableInvites")} />
@@ -156,13 +208,20 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.host.label", "SMTP Host")}</span>
<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")}
description={t(
"admin.settings.mail.host.description",
"SMTP server hostname",
)}
value={settings.host || ""}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, host: e.target.value })
}
placeholder="smtp.example.com"
/>
</div>
@@ -171,7 +230,9 @@ export default function AdminMailSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.port.label", "SMTP Port")}</span>
<span>
{t("admin.settings.mail.port.label", "SMTP Port")}
</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
@@ -180,7 +241,9 @@ export default function AdminMailSection() {
"SMTP server port (typically 587 for TLS, 465 for SSL)",
)}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
onChange={(value) =>
setSettings({ ...settings, port: Number(value) })
}
min={1}
max={65535}
/>
@@ -190,13 +253,20 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.username.label", "SMTP Username")}</span>
<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")}
description={t(
"admin.settings.mail.username.description",
"SMTP authentication username",
)}
value={settings.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, username: e.target.value })
}
/>
</div>
@@ -208,9 +278,14 @@ export default function AdminMailSection() {
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.mail.password.description", "SMTP authentication password")}
description={t(
"admin.settings.mail.password.description",
"SMTP authentication password",
)}
value={settings.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
onChange={(value) =>
setSettings({ ...settings, password: value })
}
placeholder="Enter SMTP password"
/>
</div>
@@ -219,13 +294,20 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.from.label", "From Address")}</span>
<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")}
description={t(
"admin.settings.mail.from.description",
"Email address to use as sender",
)}
value={settings.from || ""}
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
onChange={(e) =>
setSettings({ ...settings, from: e.target.value })
}
placeholder="[email protected]"
/>
</div>
@@ -242,7 +324,11 @@ export default function AdminMailSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -2,7 +2,10 @@ 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 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";
@@ -11,7 +14,10 @@ import LicenseKeySection from "@app/components/shared/config/configSections/plan
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 {
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";
@@ -57,7 +63,9 @@ const AdminPlanSection: React.FC = () => {
try {
// Only allow PRO or ENTERPRISE licenses to access billing portal
if (!licenseInfo?.licenseType || licenseInfo.licenseType === "NORMAL") {
throw new Error("No valid license found. Please purchase a license before accessing the billing portal.");
throw new Error(
"No valid license found. Please purchase a license before accessing the billing portal.",
);
}
if (!licenseInfo?.licenseKey) {
@@ -65,7 +73,10 @@ const AdminPlanSection: React.FC = () => {
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(window.location.href, licenseInfo.licenseKey);
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey,
);
// Open billing portal in new tab
window.open(response.url, "_blank");
@@ -74,7 +85,9 @@ const AdminPlanSection: React.FC = () => {
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.",
body:
(error instanceof Error ? error.message : undefined) ||
"Please try again or contact support.",
});
}
}, [licenseInfo, t, validateLoginEnabled]);
@@ -124,7 +137,8 @@ const AdminPlanSection: React.FC = () => {
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled],
);
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === "admin";
const shouldShowLicenseWarning =
licenseAlert.active && licenseAlert.audience === "admin";
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
@@ -149,7 +163,14 @@ const AdminPlanSection: React.FC = () => {
// Early returns after all hooks are called
if (loading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
);
@@ -176,7 +197,10 @@ const AdminPlanSection: React.FC = () => {
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t("plan.licenseWarning.title", "Free self-hosted limit reached")}
title={t(
"plan.licenseWarning.title",
"Free self-hosted limit reached",
)}
message={t("plan.licenseWarning.body", {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
@@ -1,6 +1,16 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TextInput, Switch, Stack, Paper, Text, Loader, Group, Alert, List } from "@mantine/core";
import {
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";
@@ -19,13 +29,26 @@ interface PremiumSettingsData {
export default function AdminPremiumSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<PremiumSettingsData>({
sectionName: "premium",
});
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: "premium",
});
useEffect(() => {
if (loginEnabled) {
@@ -33,7 +56,10 @@ export default function AdminPremiumSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
@@ -75,7 +101,10 @@ export default function AdminPremiumSection() {
{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.")}
{t(
"admin.settings.premium.description",
"Configure your premium or enterprise license key.",
)}
</Text>
</div>
@@ -83,7 +112,10 @@ export default function AdminPremiumSection() {
<Alert
variant="light"
color="blue"
title={t("admin.settings.premium.movedFeatures.title", "Premium Features Distributed")}
title={t(
"admin.settings.premium.movedFeatures.title",
"Premium Features Distributed",
)}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
@@ -127,25 +159,44 @@ export default function AdminPremiumSection() {
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.premium.key.label", "License Key")}</span>
<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")}
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 })}
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
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.premium.enabled.label", "Enable Premium Features")}
{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")}
{t(
"admin.settings.premium.enabled.description",
"Enable license key checks for pro/enterprise features",
)}
</Text>
</div>
<Group gap="xs">
@@ -174,7 +225,11 @@ export default function AdminPremiumSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -20,58 +20,75 @@ interface PrivacySettingsData {
export default function AdminPrivacySection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<PrivacySettingsData>({
sectionName: "privacy",
fetchTransformer: async (): Promise<PrivacySettingsData & { _pending?: Record<string, unknown> }> => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get("/api/v1/admin/settings/section/metrics"),
apiClient.get("/api/v1/admin/settings/section/system"),
]);
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<PrivacySettingsData>({
sectionName: "privacy",
fetchTransformer: async (): Promise<
PrivacySettingsData & { _pending?: Record<string, unknown> }
> => {
const [metricsResponse, systemResponse] = await Promise.all([
apiClient.get("/api/v1/admin/settings/section/metrics"),
apiClient.get("/api/v1/admin/settings/section/system"),
]);
const metrics = metricsResponse.data;
const system = systemResponse.data;
const metrics = metricsResponse.data;
const system = systemResponse.data;
const result: PrivacySettingsData & { _pending?: Record<string, unknown> } = {
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false,
};
const result: PrivacySettingsData & {
_pending?: Record<string, unknown>;
} = {
enableAnalytics: system.enableAnalytics || false,
googleVisibility: system.googlevisibility || false,
metricsEnabled: metrics.enabled || false,
};
// Merge pending blocks from both endpoints
const pendingBlock: Record<string, unknown> = {};
if (system._pending?.enableAnalytics !== undefined) {
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
}
if (system._pending?.googlevisibility !== undefined) {
pendingBlock.googleVisibility = system._pending.googlevisibility;
}
if (metrics._pending?.enabled !== undefined) {
pendingBlock.metricsEnabled = metrics._pending.enabled;
}
// Merge pending blocks from both endpoints
const pendingBlock: Record<string, unknown> = {};
if (system._pending?.enableAnalytics !== undefined) {
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
}
if (system._pending?.googlevisibility !== undefined) {
pendingBlock.googleVisibility = system._pending.googlevisibility;
}
if (metrics._pending?.enabled !== undefined) {
pendingBlock.metricsEnabled = metrics._pending.enabled;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
if (Object.keys(pendingBlock).length > 0) {
result._pending = pendingBlock;
}
return result;
},
saveTransformer: (settings: PrivacySettingsData) => {
const deltaSettings = {
"system.enableAnalytics": settings.enableAnalytics,
"system.googlevisibility": settings.googleVisibility,
"metrics.enabled": settings.metricsEnabled,
};
return result;
},
saveTransformer: (settings: PrivacySettingsData) => {
const deltaSettings = {
"system.enableAnalytics": settings.enableAnalytics,
"system.googlevisibility": settings.googleVisibility,
"metrics.enabled": settings.metricsEnabled,
};
return {
sectionData: {},
deltaSettings,
};
},
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -79,7 +96,10 @@ export default function AdminPrivacySection() {
}
}, [loginEnabled, fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
const original = resetToSnapshot();
@@ -124,7 +144,10 @@ export default function AdminPrivacySection() {
{t("admin.settings.privacy.title", "Privacy")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.privacy.description", "Configure privacy and data collection settings.")}
{t(
"admin.settings.privacy.description",
"Configure privacy and data collection settings.",
)}
</Text>
</div>
@@ -135,10 +158,19 @@ export default function AdminPrivacySection() {
{t("admin.settings.privacy.analytics", "Analytics & Tracking")}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.enableAnalytics.label", "Enable Analytics")}
{t(
"admin.settings.privacy.enableAnalytics.label",
"Enable Analytics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -152,7 +184,10 @@ export default function AdminPrivacySection() {
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
setSettings({
...settings,
enableAnalytics: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -161,10 +196,19 @@ export default function AdminPrivacySection() {
</Group>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.metricsEnabled.label", "Enable Metrics")}
{t(
"admin.settings.privacy.metricsEnabled.label",
"Enable Metrics",
)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
@@ -178,7 +222,10 @@ export default function AdminPrivacySection() {
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
setSettings({
...settings,
metricsEnabled: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -193,16 +240,31 @@ export default function AdminPrivacySection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.privacy.searchEngine", "Search Engine Visibility")}
{t(
"admin.settings.privacy.searchEngine",
"Search Engine Visibility",
)}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.googleVisibility.label", "Google Visibility")}
{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")}
{t(
"admin.settings.privacy.googleVisibility.description",
"Allow search engines to index this application",
)}
</Text>
</div>
<Group gap="xs">
@@ -210,7 +272,10 @@ export default function AdminPrivacySection() {
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
setSettings({
...settings,
googleVisibility: e.target.checked,
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
@@ -231,7 +296,11 @@ export default function AdminPrivacySection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}
@@ -1,6 +1,15 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Anchor, Badge, Group, Loader, Paper, Stack, Switch, Text } from "@mantine/core";
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";
@@ -34,43 +43,58 @@ interface StorageSharingSettingsData {
export default function AdminStorageSharingSection() {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
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([
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<StorageSharingSettingsData>({
sectionName: "storage",
fetchTransformer: async () => {
const [storageResponse, systemResponse, mailResponse] = await Promise.all(
[
apiClient.get("/api/v1/admin/settings/section/storage"),
apiClient.get("/api/v1/admin/settings/section/system"),
apiClient.get("/api/v1/admin/settings/section/mail"),
]);
],
);
const storageData = storageResponse.data || {};
const systemData = systemResponse.data || {};
const mailData = mailResponse.data || {};
const storageData = storageResponse.data || {};
const systemData = systemResponse.data || {};
const mailData = mailResponse.data || {};
return {
...storageData,
system: { frontendUrl: systemData.frontendUrl || "" },
mail: { enabled: mailData.enabled || false },
};
},
saveTransformer: (currentSettings) => ({
sectionData: {
enabled: currentSettings.enabled,
sharing: {
enabled: currentSettings.sharing?.enabled,
linkEnabled: currentSettings.sharing?.linkEnabled,
emailEnabled: currentSettings.sharing?.emailEnabled,
},
signing: {
enabled: currentSettings.signing?.enabled,
},
return {
...storageData,
system: { frontendUrl: systemData.frontendUrl || "" },
mail: { enabled: mailData.enabled || false },
};
},
saveTransformer: (currentSettings) => ({
sectionData: {
enabled: currentSettings.enabled,
sharing: {
enabled: currentSettings.sharing?.enabled,
linkEnabled: currentSettings.sharing?.linkEnabled,
emailEnabled: currentSettings.sharing?.emailEnabled,
},
}),
});
signing: {
enabled: currentSettings.signing?.enabled,
},
},
}),
});
useEffect(() => {
if (loginEnabled) {
@@ -83,7 +107,10 @@ export default function AdminStorageSharingSection() {
const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());
const mailEnabled = Boolean(settings.mail?.enabled);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
@@ -129,7 +156,10 @@ export default function AdminStorageSharingSection() {
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t("admin.settings.storage.description", "Control server storage and sharing options.")}
{t(
"admin.settings.storage.description",
"Control server storage and sharing options.",
)}
</Text>
</div>
@@ -138,17 +168,25 @@ export default function AdminStorageSharingSection() {
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.enabled.label", "Enable Server File Storage")}
{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.")}
{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 })}
onChange={(e) =>
setSettings({ ...settings, enabled: e.currentTarget.checked })
}
disabled={!loginEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
@@ -161,12 +199,20 @@ export default function AdminStorageSharingSection() {
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.enabled.label", "Enable Sharing")}
{t(
"admin.settings.storage.sharing.enabled.label",
"Enable Sharing",
)}
</Text>
{isFieldPending("sharing.enabled") && <PendingBadge show={true} />}
{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.")}
{t(
"admin.settings.storage.sharing.enabled.description",
"Allow users to share stored files.",
)}
</Text>
</div>
<Switch
@@ -174,7 +220,10 @@ export default function AdminStorageSharingSection() {
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, enabled: e.currentTarget.checked },
sharing: {
...settings.sharing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
@@ -189,16 +238,27 @@ export default function AdminStorageSharingSection() {
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.links.label", "Enable Share Links")}
{t(
"admin.settings.storage.sharing.links.label",
"Enable Share Links",
)}
</Text>
{isFieldPending("sharing.linkEnabled") && <PendingBadge show={true} />}
{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.")}
{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. ")}
{t(
"admin.settings.storage.sharing.links.frontendUrlNote",
"Requires a Frontend URL. ",
)}
<Anchor
href="#"
onClick={(e) => {
@@ -208,7 +268,10 @@ export default function AdminStorageSharingSection() {
c="orange"
td="underline"
>
{t("admin.settings.storage.sharing.links.frontendUrlLink", "Configure in System Settings")}
{t(
"admin.settings.storage.sharing.links.frontendUrlLink",
"Configure in System Settings",
)}
</Anchor>
</Text>
)}
@@ -218,10 +281,15 @@ export default function AdminStorageSharingSection() {
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, linkEnabled: e.currentTarget.checked },
sharing: {
...settings.sharing,
linkEnabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !sharingEnabled || !frontendUrlConfigured}
disabled={
!loginEnabled || !sharingEnabled || !frontendUrlConfigured
}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
@@ -233,16 +301,27 @@ export default function AdminStorageSharingSection() {
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.email.label", "Enable Email Sharing")}
{t(
"admin.settings.storage.sharing.email.label",
"Enable Email Sharing",
)}
</Text>
{isFieldPending("sharing.emailEnabled") && <PendingBadge show={true} />}
{isFieldPending("sharing.emailEnabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t("admin.settings.storage.sharing.email.description", "Allow sharing with email addresses.")}
{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. ")}
{t(
"admin.settings.storage.sharing.email.mailNote",
"Requires mail configuration. ",
)}
<Anchor
href="#"
onClick={(e) => {
@@ -252,7 +331,10 @@ export default function AdminStorageSharingSection() {
c="orange"
td="underline"
>
{t("admin.settings.storage.sharing.email.mailLink", "Configure Mail Settings")}
{t(
"admin.settings.storage.sharing.email.mailLink",
"Configure Mail Settings",
)}
</Anchor>
</Text>
)}
@@ -262,7 +344,10 @@ export default function AdminStorageSharingSection() {
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, emailEnabled: e.currentTarget.checked },
sharing: {
...settings.sharing,
emailEnabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
@@ -277,9 +362,14 @@ export default function AdminStorageSharingSection() {
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.signing.enabled.label", "Enable Group Signing")}
{t(
"admin.settings.storage.signing.enabled.label",
"Enable Group Signing",
)}
</Text>
{isFieldPending("signing.enabled") && <PendingBadge show={true} />}
{isFieldPending("signing.enabled") && (
<PendingBadge show={true} />
)}
</Group>
<Text size="xs" c="dimmed">
{t(
@@ -293,7 +383,10 @@ export default function AdminStorageSharingSection() {
onChange={(e) =>
setSettings({
...settings,
signing: { ...settings.signing, enabled: e.currentTarget.checked },
signing: {
...settings.signing,
enabled: e.currentTarget.checked,
},
})
}
disabled={!loginEnabled || !storageEnabled}
@@ -303,7 +396,11 @@ export default function AdminStorageSharingSection() {
</Group>
</Paper>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</Stack>
</div>
<SettingsStickyFooter
@@ -1,8 +1,19 @@
import React, { useState, useEffect, useCallback } from "react";
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from "@mantine/core";
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 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";
@@ -22,32 +33,110 @@ const AdminUsageSection: React.FC = () => {
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 [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: "merge-pdfs",
visits: 3245,
percentage: (3245 / totalVisits) * 100,
},
{
endpoint: "compress-pdf",
visits: 2891,
percentage: (2891 / totalVisits) * 100,
},
{
endpoint: "pdf-to-img",
visits: 2156,
percentage: (2156 / totalVisits) * 100,
},
{
endpoint: "split-pdf",
visits: 1834,
percentage: (1834 / totalVisits) * 100,
},
{
endpoint: "rotate-pdf",
visits: 1523,
percentage: (1523 / totalVisits) * 100,
},
{
endpoint: "ocr-pdf",
visits: 1287,
percentage: (1287 / totalVisits) * 100,
},
{
endpoint: "add-watermark",
visits: 945,
percentage: (945 / totalVisits) * 100,
},
{
endpoint: "extract-images",
visits: 782,
percentage: (782 / totalVisits) * 100,
},
{
endpoint: "add-password",
visits: 621,
percentage: (621 / totalVisits) * 100,
},
{
endpoint: "html-to-pdf",
visits: 563,
percentage: (563 / totalVisits) * 100,
},
{
endpoint: "remove-password",
visits: 487,
percentage: (487 / totalVisits) * 100,
},
{
endpoint: "pdf-to-pdfa",
visits: 423,
percentage: (423 / totalVisits) * 100,
},
{
endpoint: "extract-pdf-metadata",
visits: 356,
percentage: (356 / totalVisits) * 100,
},
{
endpoint: "add-page-numbers",
visits: 298,
percentage: (298 / totalVisits) * 100,
},
{ endpoint: "crop", visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: "flatten", visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: "sanitize-pdf", visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: "auto-split-pdf", visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: "scale-pages", visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: "compare-pdfs", visits: 42, percentage: (42 / totalVisits) * 100 },
{
endpoint: "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;
@@ -69,12 +158,18 @@ const AdminUsageSection: React.FC = () => {
setLoading(true);
setError(null);
const limit = displayMode === "all" ? undefined : displayMode === "top10" ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(limit, dataType);
const limit =
displayMode === "all" ? undefined : displayMode === "top10" ? 10 : 20;
const response = await usageAnalyticsService.getEndpointStatistics(
limit,
dataType,
);
setData(response);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load usage statistics");
setError(
err instanceof Error ? err.message : "Failed to load usage statistics",
);
} finally {
setLoading(false);
}
@@ -120,7 +215,9 @@ const AdminUsageSection: React.FC = () => {
// Early returns for loading/error states
if (actualLoading) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
<div
style={{ display: "flex", justifyContent: "center", padding: "2rem" }}
>
<Loader size="lg" />
</div>
);
@@ -128,7 +225,10 @@ const AdminUsageSection: React.FC = () => {
if (error) {
return (
<Alert color="red" title={t("usage.error", "Error loading usage statistics")}>
<Alert
color="red"
title={t("usage.error", "Error loading usage statistics")}
>
{error}
</Alert>
);
@@ -137,15 +237,21 @@ const AdminUsageSection: React.FC = () => {
if (!data) {
return (
<Alert color="yellow" title={t("usage.noData", "No data available")}>
{t("usage.noDataMessage", "No usage statistics are currently available.")}
{t(
"usage.noDataMessage",
"No usage statistics are currently available.",
)}
</Alert>
);
}
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
endpoint: endpoint.endpoint ?? t("usage.table.unknownEndpoint", "Unknown endpoint"),
endpoint:
endpoint.endpoint ?? t("usage.table.unknownEndpoint", "Unknown endpoint"),
visits: Number.isFinite(endpoint.visits) ? Math.max(0, endpoint.visits) : 0,
percentage: Number.isFinite(endpoint.percentage) ? Math.max(0, endpoint.percentage) : 0,
percentage: Number.isFinite(endpoint.percentage)
? Math.max(0, endpoint.percentage)
: 0,
}));
const chartData = endpoints.map((e) => ({
@@ -154,17 +260,27 @@ const AdminUsageSection: React.FC = () => {
}));
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
const totalVisits = Number.isFinite(data?.totalVisits) ? Math.max(0, data?.totalVisits ?? 0) : displayedVisits;
const totalEndpoints = Number.isFinite(data?.totalEndpoints) ? Math.max(0, data?.totalEndpoints ?? 0) : endpoints.length;
const totalVisits = Number.isFinite(data?.totalVisits)
? Math.max(0, data?.totalVisits ?? 0)
: displayedVisits;
const totalEndpoints = Number.isFinite(data?.totalEndpoints)
? Math.max(0, data?.totalEndpoints ?? 0)
: endpoints.length;
const displayedPercentage = totalVisits > 0 ? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1) : "0";
const displayedPercentage =
totalVisits > 0
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: "0";
return (
<Stack gap="lg">
<LoginRequiredBanner show={!loginEnabled} />
<EnterpriseRequiredBanner
show={!hasEnterpriseLicense}
featureName={t("settings.licensingAnalytics.usageAnalytics", "Usage Analytics")}
featureName={t(
"settings.licensingAnalytics.usageAnalytics",
"Usage Analytics",
)}
/>
{/* Info banner about usage analytics and audit relationship */}
@@ -187,7 +303,13 @@ const AdminUsageSection: React.FC = () => {
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.configureSettings", "Configure Analytics Settings")}
</Button>
@@ -195,7 +317,13 @@ const AdminUsageSection: React.FC = () => {
variant="light"
size="xs"
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
rightSection={
<LocalIcon
icon="arrow-forward"
width="0.9rem"
height="0.9rem"
/>
}
>
{t("usage.viewAuditLogs", "View Audit Logs")}
</Button>
@@ -211,7 +339,9 @@ const AdminUsageSection: React.FC = () => {
<Group>
<SegmentedControl
value={displayMode}
onChange={(value) => setDisplayMode(value as "top10" | "top20" | "all")}
onChange={(value) =>
setDisplayMode(value as "top10" | "top20" | "all")
}
disabled={showDemoData}
data={[
{
@@ -230,7 +360,9 @@ const AdminUsageSection: React.FC = () => {
/>
<Button
variant="outline"
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="refresh" width="1rem" height="1rem" />
}
onClick={handleRefresh}
loading={loading}
disabled={showDemoData}
@@ -14,7 +14,14 @@ export default function ApiKeys() {
const [showRefreshModal, setShowRefreshModal] = useState(false);
const { t } = useTranslation();
const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch } = useApiKey();
const {
apiKey,
isLoading: apiKeyLoading,
refresh,
isRefreshing,
error: apiKeyError,
refetch,
} = useApiKey();
const copy = async (text: string, tag: string) => {
try {
@@ -55,7 +62,10 @@ export default function ApiKeys() {
return (
<Stack gap={20} p={0}>
<Text size="sm" c="dimmed">
{t("config.apiKeys.intro", "Use your API key to programmatically access Stirling PDF's processing capabilities.")}
{t(
"config.apiKeys.intro",
"Use your API key to programmatically access Stirling PDF's processing capabilities.",
)}
</Text>
<Paper
@@ -67,13 +77,21 @@ export default function ApiKeys() {
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<LocalIcon icon="info-rounded" width={18} height={18} style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }} />
<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:")}
{t(
"config.apiKeys.docsDescription",
"Learn more about integrating with Stirling PDF:",
)}
</Text>
<Stack gap={4}>
<Text size="sm">
@@ -81,10 +99,18 @@ export default function ApiKeys() {
href="https://docs.stirlingpdf.com/API"
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.docsLink", "API Documentation")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
<Text size="sm">
@@ -92,10 +118,18 @@ export default function ApiKeys() {
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
target="_blank"
rel="noopener noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{t("config.apiKeys.schemaLink", "API Schema Reference")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
<LocalIcon
icon="open-in-new-rounded"
width={14}
height={14}
/>
</Anchor>
</Text>
</Stack>
@@ -105,8 +139,16 @@ export default function ApiKeys() {
{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(
"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>
@@ -139,10 +181,17 @@ export default function ApiKeys() {
)}
<Text size="sm" c="dimmed" style={{ marginTop: -8 }}>
{t("config.apiKeys.usage", "Include this key in the X-API-KEY header with all API requests.")}
{t(
"config.apiKeys.usage",
"Include this key in the X-API-KEY header with all API requests.",
)}
</Text>
<RefreshModal opened={showRefreshModal} onClose={() => setShowRefreshModal(false)} onConfirm={refreshKeys} />
<RefreshModal
opened={showRefreshModal}
onClose={() => setShowRefreshModal(false)}
onConfirm={refreshKeys}
/>
</Stack>
);
}
@@ -22,7 +22,10 @@ import {
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService, User } from "@app/services/userManagementService";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -48,7 +51,8 @@ export default function PeopleSection() {
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] =
useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
@@ -79,7 +83,10 @@ export default function PeopleSection() {
const addMemberTooltip = !loginEnabled
? t("workspace.people.loginRequired", "Enable login mode first")
: hasNoSlots
? t("workspace.people.license.noSlotsAvailable", "No user slots available")
? t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)
: null;
const isCurrentUser = (user: User) => currentUser?.username === user.username;
@@ -97,7 +104,10 @@ export default function PeopleSection() {
useEffect(() => {
if (config) {
console.log("[PeopleSection] Email invites enabled:", config.enableEmailInvites);
console.log(
"[PeopleSection] Email invites enabled:",
config.enableEmailInvites,
);
}
}, [config]);
@@ -106,14 +116,22 @@ export default function PeopleSection() {
setLoading(true);
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([userManagementService.getUsers(), teamService.getTeams()]);
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
// Enrich users with session data
const enrichedUsers = adminData.users.map((user) => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled: (adminData.userSettings?.[user.username] as Record<string, unknown> | undefined)?.mfaEnabled === "true",
mfaEnabled:
(
adminData.userSettings?.[user.username] as
| Record<string, unknown>
| undefined
)?.mfaEnabled === "true",
}));
setUsers(enrichedUsers);
@@ -221,14 +239,20 @@ export default function PeopleSection() {
role: editForm.role,
teamId: editForm.teamId,
});
alert({ alertType: "success", title: t("workspace.people.editMember.success") });
alert({
alertType: "success",
title: t("workspace.people.editMember.success"),
});
closeEditModal();
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to update user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) || t("workspace.people.editMember.error");
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.editMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -237,14 +261,23 @@ export default function PeopleSection() {
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
alert({ alertType: "success", title: t("workspace.people.toggleEnabled.success") });
await userManagementService.toggleUserEnabled(
user.username,
!user.enabled,
);
alert({
alertType: "success",
title: t("workspace.people.toggleEnabled.success"),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to toggle user status:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) || t("workspace.people.toggleEnabled.error");
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.toggleEnabled.error");
alert({ alertType: "error", title: errorMessage });
}
};
@@ -260,12 +293,20 @@ export default function PeopleSection() {
try {
await userManagementService.deleteUser(user.username);
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
@@ -273,21 +314,35 @@ export default function PeopleSection() {
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t("workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?");
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.unlockUser(user.username);
alert({ alertType: "success", title: t("workspace.people.unlockUserSuccess", "User account unlocked successfully") });
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.unlockUserError", "Failed to unlock user account");
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
};
@@ -320,7 +375,9 @@ export default function PeopleSection() {
});
};
const filteredUsers = users.filter((user) => user.username.toLowerCase().includes(searchQuery.toLowerCase()));
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()),
);
const roleOptions = [
{
@@ -343,14 +400,27 @@ export default function PeopleSection() {
},
];
const renderRoleOption = ({ option }: { option: ComboboxItem & { icon?: string; description?: string } }) => (
const renderRoleOption = ({
option,
}: {
option: ComboboxItem & { icon?: string; description?: string };
}) => (
<Group gap="sm" wrap="nowrap">
<LocalIcon icon={option.icon ?? ""} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
<LocalIcon
icon={option.icon ?? ""}
width="1.25rem"
height="1.25rem"
style={{ flexShrink: 0 }}
/>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{option.label}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "normal", lineHeight: 1.4 }}>
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: "normal", lineHeight: 1.4 }}
>
{option.description}
</Text>
</Box>
@@ -408,9 +478,16 @@ export default function PeopleSection() {
{licenseInfo.availableSlots === 0 && (
<Group gap="xs" wrap="nowrap" align="center">
<Badge color="red" variant="light" size="sm">
{t("workspace.people.license.noSlotsAvailable", "No slots available")}
{t(
"workspace.people.license.noSlotsAvailable",
"No slots available",
)}
</Badge>
<Button size="compact-sm" variant="outline" onClick={() => navigate("/settings/adminPlan")}>
<Button
size="compact-sm"
variant="outline"
onClick={() => navigate("/settings/adminPlan")}
>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
</Group>
@@ -420,9 +497,13 @@ export default function PeopleSection() {
<Text size="sm" c="dimmed" span>
<Text component="span" ml={4}>
{t("workspace.people.license.grandfatheredShort", "{{count}} grandfathered", {
count: licenseInfo.grandfatheredUserCount,
})}
{t(
"workspace.people.license.grandfatheredShort",
"{{count}} grandfathered",
{
count: licenseInfo.grandfatheredUserCount,
},
)}
</Text>
</Text>
)}
@@ -435,7 +516,8 @@ export default function PeopleSection() {
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
<Badge color="blue" variant="light" size="sm">
+{licenseInfo.licenseMaxUsers} {t("workspace.people.license.fromLicense", "from license")}
+{licenseInfo.licenseMaxUsers}{" "}
{t("workspace.people.license.fromLicense", "from license")}
</Badge>
)}
@@ -462,14 +544,21 @@ export default function PeopleSection() {
/>
<Tooltip
label={addMemberTooltip || undefined}
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
disabled={
loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)
}
position="bottom"
withArrow
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="person-add" width="1rem" height="1rem" />
}
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
disabled={
!loginEnabled ||
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
}
>
{t("workspace.people.addMembers")}
</Button>
@@ -489,13 +578,23 @@ export default function PeopleSection() {
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
<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}>
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
w={100}
>
{t("workspace.people.role")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
<Table.Th
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
fz="sm"
>
{t("workspace.people.team")}
</Table.Th>
<Table.Th w={50}></Table.Th>
@@ -514,7 +613,11 @@ export default function PeopleSection() {
filteredUsers.map((user) => (
<Table.Tr
key={user.id}
style={isCurrentUser(user) ? { backgroundColor: "rgba(34, 139, 230, 0.08)" } : undefined}
style={
isCurrentUser(user)
? { backgroundColor: "rgba(34, 139, 230, 0.08)" }
: undefined
}
>
<Table.Td>
<Group gap="xs" wrap="nowrap">
@@ -523,7 +626,10 @@ export default function PeopleSection() {
!user.enabled
? t("workspace.people.disabled", "Disabled")
: user.isActive
? t("workspace.people.activeSession", "Active session")
? t(
"workspace.people.activeSession",
"Active session",
)
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
@@ -533,7 +639,9 @@ export default function PeopleSection() {
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: user.isActive ? "2px solid var(--mantine-color-green-6)" : "none",
border: user.isActive
? "2px solid var(--mantine-color-green-6)"
: "none",
opacity: user.enabled ? 1 : 0.5,
},
}}
@@ -570,7 +678,12 @@ export default function PeopleSection() {
)}
</Group>
{user.email && (
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
<Text
size="xs"
c="dimmed"
truncate
style={{ lineHeight: 1.3 }}
>
{user.email}
</Text>
)}
@@ -578,7 +691,15 @@ export default function PeopleSection() {
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge size="sm" variant="light" color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}>
<Badge
size="sm"
variant="light"
color={
(user.rolesAsString || "").includes("ROLE_ADMIN")
? "blue"
: "cyan"
}
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin", "Admin")
: t("workspace.people.member", "Member")}
@@ -586,7 +707,11 @@ export default function PeopleSection() {
</Table.Td>
<Table.Td>
{user.team?.name ? (
<Tooltip label={user.team.name} disabled={user.team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Tooltip
label={user.team.name}
disabled={user.team.name.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
maw={150}
@@ -610,11 +735,13 @@ export default function PeopleSection() {
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || "Unknown"}
Authentication:{" "}
{user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
{user.lastRequest &&
new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: t("never", "Never")}
</Text>
@@ -636,50 +763,93 @@ export default function PeopleSection() {
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
{!isCurrentUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="edit"
width="1rem"
height="1rem"
/>
}
onClick={() => openEditModal(user)}
disabled={!loginEnabled}
>
{t("workspace.people.editRole", "Edit Role & Team")}
{t(
"workspace.people.editRole",
"Edit Role & Team",
)}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="lock"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{t("workspace.people.changePassword.action", "Change password")}
{t(
"workspace.people.changePassword.action",
"Change password",
)}
</Menu.Item>
)}
{!isCurrentUser(user) && (
<Menu.Item
leftSection={
user.enabled ? (
<LocalIcon icon="person-off" width="1rem" height="1rem" />
<LocalIcon
icon="person-off"
width="1rem"
height="1rem"
/>
) : (
<LocalIcon icon="person-check" width="1rem" height="1rem" />
<LocalIcon
icon="person-check"
width="1rem"
height="1rem"
/>
)
}
onClick={() => handleToggleEnabled(user)}
disabled={!loginEnabled}
>
{user.enabled ? t("workspace.people.disable") : t("workspace.people.enable")}
{user.enabled
? t("workspace.people.disable")
: t("workspace.people.enable")}
</Menu.Item>
)}
{!isCurrentUser(user) && isLockedUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock-open" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="lock-open"
width="1rem"
height="1rem"
/>
}
onClick={() => handleUnlockUser(user)}
disabled={!loginEnabled}
>
{t("workspace.people.unlockAccount", "Unlock Account")}
{t(
"workspace.people.unlockAccount",
"Unlock Account",
)}
</Menu.Item>
)}
{!isCurrentUser(user) && user.mfaEnabled && (
@@ -687,10 +857,18 @@ export default function PeopleSection() {
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="key" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="key"
width="1rem"
height="1rem"
/>
}
onClick={async () => {
try {
await userManagementService.disableMfaByAdmin(user.username);
await userManagementService.disableMfaByAdmin(
user.username,
);
alert({
alertType: "success",
title: t(
@@ -699,17 +877,33 @@ export default function PeopleSection() {
),
});
} catch (error: unknown) {
console.error("[PeopleSection] Failed to disable MFA for user:", error);
console.error(
"[PeopleSection] Failed to disable MFA for user:",
error,
);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.mfa.adminDisableError", "Failed to disable MFA for user");
alert({ alertType: "error", title: errorMessage });
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error
? error.message
: undefined) ||
t(
"workspace.people.mfa.adminDisableError",
"Failed to disable MFA for user",
);
alert({
alertType: "error",
title: errorMessage,
});
}
}}
disabled={!loginEnabled}
>
{t("workspace.people.mfa.disableByAdmin", "Disable MFA")}
{t(
"workspace.people.mfa.disableByAdmin",
"Disable MFA",
)}
</Menu.Item>
</>
)}
@@ -718,7 +912,13 @@ export default function PeopleSection() {
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="delete"
width="1rem"
height="1rem"
/>
}
onClick={() => handleDeleteUser(user)}
disabled={!loginEnabled}
>
@@ -738,7 +938,11 @@ export default function PeopleSection() {
</Table>
{/* Invite Members Modal (reusable) */}
<InviteMembersModal opened={inviteModalOpened} onClose={() => setInviteModalOpened(false)} onSuccess={fetchData} />
<InviteMembersModal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
/>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
@@ -772,32 +976,57 @@ export default function PeopleSection() {
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="edit"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.people.editMember.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.people.editMember.editing")} <strong>{selectedUser?.username}</strong>
{t("workspace.people.editMember.editing")}{" "}
<strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t("workspace.people.editMember.role")}
data={roleOptions}
value={editForm.role}
onChange={(value) => setEditForm({ ...editForm, role: value || "ROLE_USER" })}
onChange={(value) =>
setEditForm({ ...editForm, role: value || "ROLE_USER" })
}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Select
label={t("workspace.people.editMember.team")}
placeholder={t("workspace.people.editMember.teamPlaceholder")}
data={teamOptions}
value={editForm.teamId?.toString()}
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
onChange={(value) =>
setEditForm({
...editForm,
teamId: value ? parseInt(value) : undefined,
})
}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
<Button
onClick={handleUpdateUserRole}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.people.editMember.submit")}
</Button>
</Stack>
@@ -21,7 +21,10 @@ import {
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 {
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
@@ -30,17 +33,23 @@ interface TeamDetailsSectionProps {
onBack: () => void;
}
export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectionProps) {
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 [userLastRequest, setUserLastRequest] = useState<
Record<string, number>
>({});
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = 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>("");
@@ -64,11 +73,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([teamService.getTeamDetails(teamId), userManagementService.getUsers()]);
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 : []);
setAvailableUsers(
Array.isArray(data.availableUsers) ? data.availableUsers : [],
);
setUserLastRequest(data.userLastRequest || {});
// Store license information
@@ -79,7 +93,10 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
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") });
alert({
alertType: "error",
title: t("workspace.teams.loadError", "Failed to load team details"),
});
onBack();
} finally {
setLoading(false);
@@ -97,23 +114,40 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const handleAddMember = async () => {
if (!selectedUserId) {
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.selectUserRequired", "Please select a user") });
alert({
alertType: "error",
title: t(
"workspace.teams.addMemberToTeam.selectUserRequired",
"Please select a user",
),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({ alertType: "success", title: t("workspace.teams.addMemberToTeam.success", "User added to team successfully") });
alert({
alertType: "success",
title: t(
"workspace.teams.addMemberToTeam.success",
"User added to team successfully",
),
});
setAddMemberModalOpened(false);
setSelectedUserId("");
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.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");
t(
"workspace.teams.addMemberToTeam.error",
"Failed to add user to team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -121,7 +155,14 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
};
const handleRemoveMember = async (user: User) => {
if (!window.confirm(t("workspace.teams.confirmRemove", `Remove ${user.username} from this team?`))) {
if (
!window.confirm(
t(
"workspace.teams.confirmRemove",
`Remove ${user.username} from this team?`,
),
)
) {
return;
}
@@ -135,15 +176,30 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(user.username, user.rolesAsString || "ROLE_USER", defaultTeam.id);
alert({ alertType: "success", title: t("workspace.teams.removeMemberSuccess", "User removed from team") });
await teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
defaultTeam.id,
);
alert({
alertType: "success",
title: t(
"workspace.teams.removeMemberSuccess",
"User removed from team",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to remove member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.removeMemberError", "Failed to remove user from team");
t(
"workspace.teams.removeMemberError",
"Failed to remove user from team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -162,12 +218,20 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({ alertType: "success", title: t("workspace.people.deleteUserSuccess", "User deleted successfully") });
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
@@ -177,21 +241,35 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t("workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?");
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
await userManagementService.unlockUser(user.username);
alert({ alertType: "success", title: t("workspace.people.unlockUserSuccess", "User account unlocked successfully") });
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("[TeamDetailsSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message || error.response?.data?.error || error.message
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.unlockUserError", "Failed to unlock user account");
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
};
@@ -214,7 +292,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const handleChangeTeam = async () => {
if (!selectedUser || !selectedTeamId) {
alert({ alertType: "error", title: t("workspace.teams.changeTeam.selectTeamRequired", "Please select a team") });
alert({
alertType: "error",
title: t(
"workspace.teams.changeTeam.selectTeamRequired",
"Please select a team",
),
});
return;
}
@@ -225,7 +309,13 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
selectedUser.rolesAsString || "ROLE_USER",
parseInt(selectedTeamId),
);
alert({ alertType: "success", title: t("workspace.teams.changeTeam.success", "Team changed successfully") });
alert({
alertType: "success",
title: t(
"workspace.teams.changeTeam.success",
"Team changed successfully",
),
});
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId("");
@@ -233,7 +323,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
} 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.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 });
@@ -278,7 +370,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
{team.name}
</Text>
<Text size="sm" c="dimmed">
{t("workspace.teams.memberCount", { count: teamUsers.length })} {teamUsers.length === 1 ? "member" : "members"}
{t("workspace.teams.memberCount", { count: teamUsers.length })}{" "}
{teamUsers.length === 1 ? "member" : "members"}
</Text>
</div>
</Group>
@@ -286,16 +379,24 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
{/* Add Member Button */}
<Group justify="flex-end">
<Tooltip
label={t("workspace.people.license.noSlotsAvailable", "No user slots available")}
label={t(
"workspace.people.license.noSlotsAvailable",
"No user slots available",
)}
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="person-add" width="1rem" height="1rem" />
}
onClick={() => setAddMemberModalOpened(true)}
disabled={team.name === "Internal" || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
disabled={
team.name === "Internal" ||
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
}
>
{t("workspace.teams.addMember")}
</Button>
@@ -315,10 +416,17 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
<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}>
<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>
@@ -335,7 +443,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
</Table.Tr>
) : (
teamUsers.map((user) => {
const isActive = userLastRequest[user.username] && Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
const isActive =
userLastRequest[user.username] &&
Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
return (
<Table.Tr key={user.id}>
@@ -346,7 +456,10 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
!user.enabled
? t("workspace.people.disabled", "Disabled")
: isActive
? t("workspace.people.activeSession", "Active session")
? t(
"workspace.people.activeSession",
"Active session",
)
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
@@ -356,7 +469,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: isActive ? "2px solid var(--mantine-color-green-6)" : "none",
border: isActive
? "2px solid var(--mantine-color-green-6)"
: "none",
opacity: user.enabled ? 1 : 0.5,
},
}}
@@ -393,7 +508,12 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
)}
</Group>
{user.email && (
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
<Text
size="xs"
c="dimmed"
truncate
style={{ lineHeight: 1.3 }}
>
{user.email}
</Text>
)}
@@ -403,7 +523,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Table.Td w={100}>
<Badge
size="sm"
color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}
color={
(user.rolesAsString || "").includes("ROLE_ADMIN")
? "blue"
: "cyan"
}
variant="light"
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
@@ -418,12 +542,15 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || "Unknown"}
Authentication:{" "}
{user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{userLastRequest[user.username]
? new Date(userLastRequest[user.username]).toLocaleString()
? new Date(
userLastRequest[user.username],
).toLocaleString()
: "Never"}
</Text>
</div>
@@ -443,46 +570,95 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
<LocalIcon
icon="more-vert"
width="1rem"
height="1rem"
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="swap-horiz"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === "Internal"}
>
{t("workspace.teams.changeTeam.label", "Change Team")}
{t(
"workspace.teams.changeTeam.label",
"Change Team",
)}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="lock"
width="1rem"
height="1rem"
/>
}
onClick={() => openChangePasswordModal(user)}
disabled={processing}
>
{t("workspace.people.changePassword.action", "Change password")}
{t(
"workspace.people.changePassword.action",
"Change password",
)}
</Menu.Item>
{isLockedUser(user) && (
<Menu.Item
leftSection={<LocalIcon icon="lock-open" width="1rem" height="1rem" />}
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")}
{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" />}
leftSection={
<LocalIcon
icon="delete"
width="1rem"
height="1rem"
/>
}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === "Internal"}
>
@@ -531,18 +707,26 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")} <strong>{team.name}</strong>
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{team.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t("workspace.teams.addMemberToTeam.selectUserPlaceholder")}
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})` : ""}`,
@@ -550,16 +734,27 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
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>
)}
{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">
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
@@ -590,18 +785,27 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="swap-horiz" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="swap-horiz"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.changeTeam.title", "Change Team")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.changeTeam.changing", "Moving")} <strong>{selectedUser?.username}</strong>
{t("workspace.teams.changeTeam.changing", "Moving")}{" "}
<strong>{selectedUser?.username}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
placeholder={t("workspace.teams.changeTeam.selectTeamPlaceholder", "Choose a team")}
placeholder={t(
"workspace.teams.changeTeam.selectTeamPlaceholder",
"Choose a team",
)}
data={allTeams
.filter((t) => t.name !== "Internal")
.map((team) => ({
@@ -611,10 +815,19 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
/>
<Button onClick={handleChangeTeam} loading={processing} fullWidth size="md" mt="md">
<Button
onClick={handleChangeTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.changeTeam.submit", "Change Team")}
</Button>
</Stack>
@@ -20,7 +20,10 @@ import {
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 {
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";
@@ -73,22 +76,31 @@ export default function TeamsSection() {
const handleCreateTeam = async () => {
if (!newTeamName.trim()) {
alert({ alertType: "error", title: t("workspace.teams.createTeam.nameRequired") });
alert({
alertType: "error",
title: t("workspace.teams.createTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({ alertType: "success", title: t("workspace.teams.createTeam.success") });
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");
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.createTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
@@ -97,14 +109,20 @@ export default function TeamsSection() {
const handleRenameTeam = async () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({ alertType: "error", title: t("workspace.teams.renameTeam.nameRequired") });
alert({
alertType: "error",
title: t("workspace.teams.renameTeam.nameRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({ alertType: "success", title: t("workspace.teams.renameTeam.success") });
alert({
alertType: "success",
title: t("workspace.teams.renameTeam.success"),
});
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
@@ -112,8 +130,11 @@ export default function TeamsSection() {
} 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");
? 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);
@@ -122,7 +143,10 @@ export default function TeamsSection() {
const handleDeleteTeam = async (team: Team) => {
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotDeleteInternal") });
alert({
alertType: "error",
title: t("workspace.teams.cannotDeleteInternal"),
});
return;
}
@@ -132,20 +156,29 @@ export default function TeamsSection() {
try {
await teamService.deleteTeam(team.id);
alert({ alertType: "success", title: t("workspace.teams.deleteTeam.success") });
alert({
alertType: "success",
title: t("workspace.teams.deleteTeam.success"),
});
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to delete team:", error);
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");
? 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") });
alert({
alertType: "error",
title: t("workspace.teams.cannotRenameInternal"),
});
return;
}
setSelectedTeam(team);
@@ -155,7 +188,10 @@ export default function TeamsSection() {
const openAddMemberModal = async (team: Team) => {
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotAddToInternal") });
alert({
alertType: "error",
title: t("workspace.teams.cannotAddToInternal"),
});
return;
}
setSelectedTeam(team);
@@ -166,27 +202,42 @@ export default function TeamsSection() {
setAddMemberModalOpened(true);
} catch (error) {
console.error("Failed to fetch users:", error);
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.error") });
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.userRequired") });
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.userRequired"),
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: "success", title: t("workspace.teams.addMemberToTeam.success") });
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") });
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
} finally {
setProcessing(false);
}
@@ -253,10 +304,22 @@ export default function TeamsSection() {
>
<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)" }}>
<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)" }}>
<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>
@@ -280,7 +343,11 @@ export default function TeamsSection() {
>
<Table.Td>
<Group gap="xs">
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Tooltip
label={team.name}
disabled={team.name.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
@@ -309,27 +376,47 @@ export default function TeamsSection() {
<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
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.Dropdown
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
>
<Menu.Item
leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />}
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" />}
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" />}
leftSection={
<LocalIcon icon="edit" width="1rem" height="1rem" />
}
onClick={() => openRenameModal(team)}
disabled={!loginEnabled}
>
@@ -338,7 +425,9 @@ export default function TeamsSection() {
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="delete" width="1rem" height="1rem" />
}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === "Internal"}
>
@@ -377,7 +466,12 @@ export default function TeamsSection() {
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="group-add" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="group-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.createTeam.title")}
</Text>
@@ -391,7 +485,13 @@ export default function TeamsSection() {
required
/>
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
<Button
onClick={handleCreateTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.createTeam.submit")}
</Button>
</Stack>
@@ -422,24 +522,38 @@ export default function TeamsSection() {
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="edit"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.renameTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.renameTeam.renaming")} <strong>{selectedTeam?.name}</strong>
{t("workspace.teams.renameTeam.renaming")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<TextInput
label={t("workspace.teams.renameTeam.newTeamName")}
placeholder={t("workspace.teams.renameTeam.newTeamNamePlaceholder")}
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">
<Button
onClick={handleRenameTeam}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.renameTeam.submit")}
</Button>
</Stack>
@@ -470,18 +584,26 @@ export default function TeamsSection() {
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: "var(--mantine-color-gray-6)" }} />
<LocalIcon
icon="person-add"
width="3rem"
height="3rem"
style={{ color: "var(--mantine-color-gray-6)" }}
/>
<Text size="xl" fw={600} ta="center">
{t("workspace.teams.addMemberToTeam.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("workspace.teams.addMemberToTeam.addingTo")} <strong>{selectedTeam?.name}</strong>
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
<strong>{selectedTeam?.name}</strong>
</Text>
</Stack>
<Select
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t("workspace.teams.addMemberToTeam.selectUserPlaceholder")}
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})` : ""}`,
@@ -489,16 +611,27 @@ export default function TeamsSection() {
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
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>
)}
{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">
<Button
onClick={handleAddMember}
loading={processing}
fullWidth
size="md"
mt="md"
>
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
@@ -12,7 +12,13 @@ interface ApiKeySectionProps {
disabled?: boolean;
}
export default function ApiKeySection({ publicKey, copied, onCopy, onRefresh, disabled }: ApiKeySectionProps) {
export default function ApiKeySection({
publicKey,
copied,
onCopy,
onRefresh,
disabled,
}: ApiKeySectionProps) {
const { t } = useTranslation();
return (
<>
@@ -40,7 +46,10 @@ export default function ApiKeySection({ publicKey, copied, onCopy, onRefresh, di
display: "flex",
alignItems: "center",
}}
aria-label={t("config.apiKeys.publicKeyAriaLabel", "Public API key")}
aria-label={t(
"config.apiKeys.publicKeyAriaLabel",
"Public API key",
)}
>
<FitText text={publicKey} />
</Box>
@@ -49,7 +58,9 @@ export default function ApiKeySection({ publicKey, copied, onCopy, onRefresh, di
size="sm"
variant="light"
onClick={() => onCopy(publicKey, "public")}
leftSection={<LocalIcon icon="content-copy-rounded" width={14} height={14} />}
leftSection={
<LocalIcon icon="content-copy-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
@@ -60,13 +71,17 @@ export default function ApiKeySection({ publicKey, copied, onCopy, onRefresh, di
}}
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
>
{copied === "public" ? t("common.copied", "Copied!") : t("common.copy", "Copy")}
{copied === "public"
? t("common.copied", "Copied!")
: t("common.copy", "Copy")}
</Button>
<Button
size="sm"
variant="light"
onClick={onRefresh}
leftSection={<LocalIcon icon="refresh-rounded" width={14} height={14} />}
leftSection={
<LocalIcon icon="refresh-rounded" width={14} height={14} />
}
styles={{
root: {
background: "var(--api-keys-button-bg)",
@@ -9,7 +9,11 @@ interface RefreshModalProps {
onConfirm: () => void;
}
export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModalProps) {
export default function RefreshModal({
opened,
onClose,
onConfirm,
}: RefreshModalProps) {
const { t } = useTranslation();
return (
<Modal
@@ -34,7 +38,10 @@ export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModa
)}
</Text>
<Text size="sm" fw={500}>
{t("config.apiKeys.refreshModal.confirmPrompt", "Are you sure you want to continue?")}
{t(
"config.apiKeys.refreshModal.confirmPrompt",
"Are you sure you want to continue?",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
@@ -15,7 +15,10 @@ export function useApiKey() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToCreateApiKey", "Failed to create API key."),
body: t(
"config.apiKeys.alert.failedToCreateApiKey",
"Failed to create API key.",
),
isPersistentPopup: false,
});
}
@@ -37,7 +40,10 @@ export function useApiKey() {
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."),
body: t(
"config.apiKeys.alert.failedToRetrieveApiKey",
"Failed to retrieve API key from response.",
),
isPersistentPopup: false,
});
}
@@ -48,7 +54,10 @@ export function useApiKey() {
await apiClient
.post("/api/v1/user/update-api-key")
.then((createRes) => {
const created = typeof createRes.data === "string" ? createRes.data : createRes.data?.apiKey;
const created =
typeof createRes.data === "string"
? createRes.data
: createRes.data?.apiKey;
if (typeof created === "string") {
setApiKey(created);
} else {
@@ -63,7 +72,10 @@ export function useApiKey() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToFetchApiKey", "Failed to fetch API key."),
body: t(
"config.apiKeys.alert.failedToFetchApiKey",
"Failed to fetch API key.",
),
isPersistentPopup: false,
});
setError(e);
@@ -84,12 +96,19 @@ export function useApiKey() {
suppressErrorToast: true,
})
.then((res) => {
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
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."),
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);
@@ -97,7 +116,10 @@ export function useApiKey() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
}
@@ -106,7 +128,10 @@ export function useApiKey() {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."),
body: t(
"config.apiKeys.alert.failedToRefreshApiKey",
"Failed to refresh API key.",
),
isPersistentPopup: false,
});
setError(e);
@@ -122,7 +147,15 @@ export function useApiKey() {
}
}, [hasAttempted, fetchKey]);
return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const;
return {
apiKey,
isLoading,
isRefreshing,
error,
refetch: fetchKey,
refresh,
hasAttempted,
} as const;
}
export default useApiKey;
@@ -1,6 +1,27 @@
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 {
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";
@@ -45,7 +66,11 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
const data = await auditService.getChartsData(timePeriod);
setChartsData(data);
} catch (err) {
setError(err instanceof Error ? err.message : t("audit.charts.error", "Failed to load charts"));
setError(
err instanceof Error
? err.message
: t("audit.charts.error", "Failed to load charts"),
);
} finally {
setLoading(false);
}
@@ -57,7 +82,13 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
// Demo data when login disabled
setChartsData({
eventsByType: {
labels: ["LOGIN", "LOGOUT", "SETTINGS_CHANGE", "FILE_UPLOAD", "FILE_DOWNLOAD"],
labels: [
"LOGIN",
"LOGOUT",
"SETTINGS_CHANGE",
"FILE_UPLOAD",
"FILE_DOWNLOAD",
],
values: [342, 289, 145, 678, 523],
},
eventsByUser: {
@@ -85,7 +116,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
if (error) {
return (
<Alert color="red" title={t("audit.charts.error", "Error loading charts")}>
<Alert
color="red"
title={t("audit.charts.error", "Error loading charts")}
>
{error}
</Alert>
);
@@ -96,20 +130,26 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
}
// Transform data for Recharts
const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({
name: label,
value: chartsData.eventsOverTime.values[index],
}));
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 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],
}));
const eventsByUserData = chartsData.eventsByUser.labels.map(
(label, index) => ({
user: label,
value: chartsData.eventsByUser.values[index],
}),
);
return (
<Stack gap="lg">
@@ -144,11 +184,22 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<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} />
<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)" />
<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
@@ -170,7 +221,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t("audit.charts.noData", "No data for this period")}</Text>
<Text c="dimmed">
{t("audit.charts.noData", "No data for this period")}
</Text>
</Group>
)}
</Box>
@@ -189,8 +242,17 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
{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} />
<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={{
@@ -202,7 +264,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
/>
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
{eventsByTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getEventTypeColor(entry.type)} />
<Cell
key={`cell-${index}`}
fill={getEventTypeColor(entry.type)}
/>
))}
</Bar>
</BarChart>
@@ -226,9 +291,17 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--mantine-color-gray-2)" />
<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} />
<YAxis
type="category"
dataKey="user"
stroke="var(--mantine-color-gray-6)"
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--mantine-color-gray-8)",
@@ -1,5 +1,15 @@
import React, { useState } from "react";
import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from "@mantine/core";
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";
@@ -8,7 +18,9 @@ interface AuditClearDataSectionProps {
loginEnabled?: boolean;
}
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnabled = true }) => {
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({
loginEnabled = true,
}) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState("");
const [generatedCode, setGeneratedCode] = useState("");
@@ -47,7 +59,9 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
// Auto-dismiss success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to clear audit data");
setError(
err instanceof Error ? err.message : "Failed to clear audit data",
);
} finally {
setClearing(false);
}
@@ -58,13 +72,18 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Stack gap="lg">
<Alert
color="green"
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
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")}
{t(
"audit.clearData.successMessage",
"All audit data has been cleared successfully",
)}
</Alert>
</Stack>
);
@@ -76,7 +95,10 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="orange"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t("audit.clearData.confirmTitle", "Please confirm you want to delete")}
title={t(
"audit.clearData.confirmTitle",
"Please confirm you want to delete",
)}
>
<Text size="sm">
{t(
@@ -86,7 +108,12 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: "var(--mantine-color-red-4)" }}>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<div
style={{
@@ -112,13 +139,19 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
{generatedCode}
</Code>
<Text size="xs" c="dimmed" mt="xs">
{t("audit.clearData.enterCodeBelow", "Enter the code exactly as shown above (case-sensitive)")}
{t(
"audit.clearData.enterCodeBelow",
"Enter the code exactly as shown above (case-sensitive)",
)}
</Text>
</div>
<PasswordInput
label={t("audit.clearData.enterCode", "Confirmation Code")}
placeholder={t("audit.clearData.codePlaceholder", "Type the code here")}
placeholder={t(
"audit.clearData.codePlaceholder",
"Type the code here",
)}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
disabled={!loginEnabled}
@@ -130,7 +163,10 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
/>
{error && (
<Alert color="red" icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}>
<Alert
color="red"
icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}
>
{error}
</Alert>
)}
@@ -143,7 +179,11 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
color="red"
onClick={handleClearData}
loading={clearing}
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
disabled={
!loginEnabled ||
!generatedCode ||
confirmationCode !== generatedCode
}
>
{t("audit.clearData.deleteButton", "Delete")}
</Button>
@@ -169,14 +209,31 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: "var(--mantine-color-red-4)" }}>
<Card
padding="lg"
radius="md"
withBorder
style={{ borderColor: "var(--mantine-color-red-4)" }}
>
<Stack gap="md">
<Group>
<Text fw={600}>{t("audit.clearData.confirmationRequired", "Delete All Audit Data")}</Text>
<Badge color="red">{t("audit.clearData.irreversible", "IRREVERSIBLE")}</Badge>
<Text fw={600}>
{t(
"audit.clearData.confirmationRequired",
"Delete All Audit Data",
)}
</Text>
<Badge color="red">
{t("audit.clearData.irreversible", "IRREVERSIBLE")}
</Badge>
</Group>
<Button color="red" onClick={handleInitiateDeletion} disabled={!loginEnabled} fullWidth>
<Button
color="red"
onClick={handleInitiateDeletion}
disabled={!loginEnabled}
fullWidth
>
{t("audit.clearData.initiateDelete", "Delete All Data")}
</Button>
</Stack>
@@ -15,7 +15,10 @@ import {
UnstyledButton,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, { AuditEvent, AuditFilters } from "@app/services/auditService";
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";
@@ -39,20 +42,23 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
const [sortKey, setSortKey] = useState<"timestamp" | "eventType" | "username" | "ipAddress" | null>("timestamp");
const [sortKey, setSortKey] = useState<
"timestamp" | "eventType" | "username" | "ipAddress" | null
>("timestamp");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const showAuthor = capturePdfAuthor;
const showFileHash = captureFileHash;
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
// Use shared filters hook
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } = useAuditFilters(
{
page: 0,
pageSize: 20,
},
loginEnabled,
);
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters(
{
page: 0,
pageSize: 20,
},
loginEnabled,
);
useEffect(() => {
const fetchEvents = async () => {
@@ -125,7 +131,10 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
}, [filters, currentPage, loginEnabled]);
// Wrap filter handlers to reset pagination
const handleFilterChangeWithReset = (key: keyof AuditFilters, value: AuditFilters[keyof AuditFilters]) => {
const handleFilterChangeWithReset = (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => {
handleFilterChange(key, value);
setCurrentPage(1);
};
@@ -140,7 +149,9 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
};
// Sort handling
const toggleSort = (key: "timestamp" | "eventType" | "username" | "ipAddress") => {
const toggleSort = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey === key) {
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
@@ -149,7 +160,9 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
}
};
const getSortIcon = (key: "timestamp" | "eventType" | "username" | "ipAddress") => {
const getSortIcon = (
key: "timestamp" | "eventType" | "username" | "ipAddress",
) => {
if (sortKey !== key) return "unfold-more";
return sortDir === "asc" ? "expand-less" : "expand-more";
};
@@ -225,12 +238,21 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Loader size="lg" my="xl" />
</div>
) : error ? (
<Alert color="red" title={t("audit.events.error", "Error loading events")}>
<Alert
color="red"
title={t("audit.events.error", "Error loading events")}
>
{error}
</Alert>
) : (
<>
<div style={{ overflowX: "auto", overflowY: "hidden", marginBottom: "1rem" }}>
<div
style={{
overflowX: "auto",
overflowY: "hidden",
marginBottom: "1rem",
}}
>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
@@ -243,48 +265,126 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
}
>
<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">
<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" }}
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" />
<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">
<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" }}
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" />
<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">
<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" }}
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" />
<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">
<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">
<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">
<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">
<Table.Th
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
ta="center"
>
{t("audit.events.actions", "Actions")}
</Table.Th>
</Table.Tr>
@@ -295,7 +395,12 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<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 }} />
<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>
@@ -310,15 +415,26 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
let author = "";
let fileHash = "";
if (event.details && typeof event.details === "object") {
const details = event.details as Record<string, unknown>;
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 : "";
documentName =
typeof firstFile.name === "string"
? firstFile.name
: "";
if (showAuthor || showFileHash) {
author = typeof firstFile.pdfAuthor === "string" ? firstFile.pdfAuthor : "";
author =
typeof firstFile.pdfAuthor === "string"
? firstFile.pdfAuthor
: "";
fileHash =
typeof firstFile.fileHash === "string" ? firstFile.fileHash.substring(0, 16) + "..." : "";
typeof firstFile.fileHash === "string"
? firstFile.fileHash.substring(0, 16) + "..."
: "";
}
}
}
@@ -329,7 +445,11 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
<Badge
variant="light"
size="sm"
color={getEventTypeColor(event.eventType)}
>
{event.eventType}
</Badge>
</Table.Td>
@@ -348,7 +468,14 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
)}
{showFileHash && (
<Table.Td>
<Text size="sm" title={fileHash} style={{ fontFamily: "monospace", fontSize: "0.75rem" }}>
<Text
size="sm"
title={fileHash}
style={{
fontFamily: "monospace",
fontSize: "0.75rem",
}}
>
{fileHash}
</Text>
</Table.Td>
@@ -373,7 +500,11 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination value={currentPage} onChange={setCurrentPage} total={totalPages} />
<Pagination
value={currentPage}
onChange={setCurrentPage}
total={totalPages}
/>
</Group>
)}
</div>
@@ -1,5 +1,13 @@
import React, { useState } from "react";
import { Card, Text, Group, Stack, Button, SegmentedControl, Checkbox } from "@mantine/core";
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";
@@ -22,20 +30,23 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
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,
});
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 { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
useAuditFilters({}, loginEnabled);
const handleExport = async () => {
if (!loginEnabled) return;
@@ -47,7 +58,10 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
.filter((k) => selectedFields[k])
.join(",");
const blob = await auditService.exportData(exportFormat, { ...filters, fields: fieldsParam });
const blob = await auditService.exportData(exportFormat, {
...filters,
fields: fieldsParam,
});
// Create download link
const url = window.URL.createObjectURL(blob);
@@ -74,7 +88,10 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
</Text>
<Text size="sm" c="dimmed">
{t("audit.export.description", "Export audit events to CSV or JSON format. Use filters to limit the exported data.")}
{t(
"audit.export.description",
"Export audit events to CSV or JSON format. Use filters to limit the exported data.",
)}
</Text>
{/* Format Selection */}
@@ -105,44 +122,82 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
<Checkbox
label={t("audit.export.fieldDate", "Date")}
checked={selectedFields.date}
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
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 })}
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 })}
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 })}
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 })}
onChange={(e) =>
setSelectedFields({
...selectedFields,
documentName: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldOutcome", "Outcome (Success/Failure)")}
label={t(
"audit.export.fieldOutcome",
"Outcome (Success/Failure)",
)}
checked={selectedFields.outcome}
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
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 })}
onChange={(e) =>
setSelectedFields({
...selectedFields,
author: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
@@ -150,15 +205,28 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
<Checkbox
label={t("audit.export.fieldFileHash", "File Hash (SHA-256)")}
checked={selectedFields.fileHash}
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
onChange={(e) =>
setSelectedFields({
...selectedFields,
fileHash: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t("audit.export.fieldOperationResults", "Operation Results")}
label={t(
"audit.export.fieldOperationResults",
"Operation Results",
)}
checked={selectedFields.operationResults}
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
onChange={(e) =>
setSelectedFields({
...selectedFields,
operationResults: e.currentTarget.checked,
})
}
disabled={!loginEnabled}
/>
)}
@@ -183,7 +251,9 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
{/* Export Button */}
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="download" width="1rem" height="1rem" />}
leftSection={
<LocalIcon icon="download" width="1rem" height="1rem" />
}
onClick={handleExport}
loading={exporting}
disabled={!loginEnabled || exporting}
@@ -1,5 +1,12 @@
import React from "react";
import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from "@mantine/core";
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";
@@ -43,7 +50,10 @@ interface AuditFiltersFormProps {
filters: AuditFilters;
eventTypes: string[];
users: string[];
onFilterChange: (key: keyof AuditFilters, value: AuditFilters[keyof AuditFilters]) => void;
onFilterChange: (
key: keyof AuditFilters,
value: AuditFilters[keyof AuditFilters],
) => void;
onClearFilters: () => void;
disabled?: boolean;
}
@@ -77,7 +87,10 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
const [expectedStart, expectedEnd] = range;
const expectedStartStr = formatDateToYMD(expectedStart);
const expectedEndStr = formatDateToYMD(expectedEnd);
return filters.startDate === expectedStartStr && filters.endDate === expectedEndStr;
return (
filters.startDate === expectedStartStr &&
filters.endDate === expectedEndStr
);
};
return (
@@ -128,47 +141,86 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
<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)}
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 }}
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)}
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 }}
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);
onFilterChange(
"startDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
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);
onFilterChange(
"endDate",
value ? formatDateToYMD(new Date(value)) : undefined,
);
}}
clearable
disabled={disabled}
popoverProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
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}>
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
disabled={disabled}
>
{t("audit.events.clearFilters", "Clear")}
</Button>
</Group>
@@ -1,5 +1,14 @@
import React, { useState, useEffect } from "react";
import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from "@mantine/core";
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";
@@ -9,7 +18,10 @@ interface AuditStatsCardsProps {
timePeriod: "day" | "week" | "month";
}
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true, timePeriod = "week" }) => {
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({
loginEnabled = true,
timePeriod = "week",
}) => {
const { t } = useTranslation();
const [stats, setStats] = useState<AuditStats | null>(null);
const [loading, setLoading] = useState(true);
@@ -23,7 +35,9 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
const data = await auditService.getStats(timePeriod);
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load statistics");
setError(
err instanceof Error ? err.message : "Failed to load statistics",
);
} finally {
setLoading(false);
}
@@ -57,7 +71,14 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
if (loading) {
return (
<Card padding="lg" radius="md" withBorder>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", padding: "2rem 0" }}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "2rem 0",
}}
>
<Loader size="lg" />
</div>
</Card>
@@ -66,7 +87,10 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
if (error) {
return (
<Alert color="red" title={t("audit.stats.error", "Error loading statistics")}>
<Alert
color="red"
title={t("audit.stats.error", "Error loading statistics")}
>
{error}
</Alert>
);
@@ -77,12 +101,23 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
}
const trendPercent =
stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0;
stats.prevTotalEvents > 0
? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) *
100
: 0;
const userTrend =
stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0;
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;
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";
@@ -130,7 +165,8 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
/>
}
>
{Math.abs(trendPercent).toFixed(1)}% {t("audit.stats.vsLastPeriod", "vs last period")}
{Math.abs(trendPercent).toFixed(1)}%{" "}
{t("audit.stats.vsLastPeriod", "vs last period")}
</Badge>
)}
</Stack>
@@ -143,13 +179,21 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Text size="sm" c="dimmed">
{t("audit.stats.successRate", "Success Rate")}
</Text>
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
<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">
<Badge
color={getSuccessRateColor(stats.successRate)}
variant="light"
size="sm"
>
{stats.successRate >= 95
? t("audit.stats.excellent", "Excellent")
: stats.successRate >= 80
@@ -157,7 +201,11 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
: t("audit.stats.attention", "Attention needed")}
</Badge>
{successTrend !== 0 && (
<Badge color={getTrendColor(successTrend)} variant="light" size="xs">
<Badge
color={getTrendColor(successTrend)}
variant="light"
size="xs"
>
{successTrend > 0 ? "+" : ""}
{successTrend.toFixed(1)}%
</Badge>
@@ -184,7 +232,12 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
variant="light"
size="sm"
leftSection={
<LocalIcon icon={getTrendIcon(userTrend)} width="0.8rem" height="0.8rem" style={{ marginRight: "0.25rem" }} />
<LocalIcon
icon={getTrendIcon(userTrend)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: "0.25rem" }}
/>
}
>
{Math.abs(userTrend).toFixed(1)}%
@@ -203,7 +256,9 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t("audit.stats.noData", "N/A")}
{stats.avgLatencyMs > 0
? `${stats.avgLatencyMs.toFixed(0)}ms`
: t("audit.stats.noData", "N/A")}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
@@ -22,8 +22,15 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<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
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>
@@ -74,19 +81,49 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<Badge color="green" variant="light" size="sm">
{t("audit.systemStatus.date", "Date")}
</Badge>
<Badge color={status.capturePdfAuthor ? "green" : "gray"} variant="light" size="sm">
<Badge
color={status.capturePdfAuthor ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.pdfAuthor", "PDF Author")}
{!status.capturePdfAuthor && (
<span style={{ marginLeft: "0.5rem", fontSize: "0.75rem", opacity: 0.7 }}>
({t("audit.systemStatus.captureBySettings", "Enable in settings")})
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
<Badge color={status.captureFileHash ? "green" : "gray"} variant="light" size="sm">
<Badge
color={status.captureFileHash ? "green" : "gray"}
variant="light"
size="sm"
>
{t("audit.systemStatus.fileHash", "File Hash")}
{!status.captureFileHash && (
<span style={{ marginLeft: "0.5rem", fontSize: "0.75rem", opacity: 0.7 }}>
({t("audit.systemStatus.captureBySettings", "Enable in settings")})
<span
style={{
marginLeft: "0.5rem",
fontSize: "0.75rem",
opacity: 0.7,
}}
>
(
{t(
"audit.systemStatus.captureBySettings",
"Enable in settings",
)}
)
</span>
)}
</Badge>
@@ -1,11 +1,19 @@
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 licenseService, {
PlanTier,
PlanTierGroup,
LicenseInfo,
mapLicenseToTier,
} from "@app/services/licenseService";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from "@app/utils/planTierUtils";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
} from "@app/utils/planTierUtils";
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -56,7 +64,13 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<div>
<Group justify="space-between" align="flex-start" mb="xs">
<div>
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
@@ -66,7 +80,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
fontSize: "0.875rem",
}}
>
{t("plan.availablePlans.subtitle", "Choose the plan that fits your needs")}
{t(
"plan.availablePlans.subtitle",
"Choose the plan that fits your needs",
)}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
@@ -77,7 +94,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
comboboxProps={{
withinPortal: true,
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
}}
disabled={!loginEnabled}
/>
)}
@@ -107,7 +127,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<div style={{ textAlign: "center" }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
@@ -115,7 +138,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable plans={groupedPlans} currentTier={currentTier} />
<FeatureComparisonTable
plans={groupedPlans}
currentTier={currentTier}
/>
</Collapse>
</div>
);
@@ -15,7 +15,10 @@ interface FeatureComparisonTableProps {
currentTier?: "free" | "server" | "enterprise" | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans, currentTier }) => {
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
plans,
currentTier,
}) => {
const { t } = useTranslation();
return (
@@ -27,8 +30,12 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans,
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "2px solid var(--mantine-color-gray-3)" }}>
<th style={{ textAlign: "left", padding: "0.75rem" }}>{t("plan.feature.title", "Feature")}</th>
<tr
style={{ borderBottom: "2px solid var(--mantine-color-gray-3)" }}
>
<th style={{ textAlign: "left", padding: "0.75rem" }}>
{t("plan.feature.title", "Feature")}
</th>
{plans.map((plan, index) => (
<th
key={plan.tier || plan.name || index}
@@ -40,30 +47,43 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans,
}}
>
{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>
)}
{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>
<tr
key={featureIndex}
style={{
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<td style={{ padding: "0.75rem" }}>
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan, planIndex) => (
<td key={planIndex} style={{ textAlign: "center", padding: "0.75rem" }}>
<td
key={planIndex}
style={{ textAlign: "center", padding: "0.75rem" }}
>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
@@ -1,5 +1,16 @@
import React, { useState } from "react";
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from "@mantine/core";
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";
@@ -12,7 +23,9 @@ interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInfo }) => {
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({
currentLicenseInfo,
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
@@ -43,7 +56,10 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key or file"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key or file",
),
});
return;
}
@@ -54,8 +70,14 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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");
? 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",
@@ -71,7 +93,9 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: response.error || t("admin.settings.saveError", "Failed to save license"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
@@ -91,16 +115,29 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<Button
variant="subtle"
leftSection={
<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />
<LocalIcon
icon={
showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"
}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t("admin.settings.premium.licenseKey.toggle", "Got a license key or certificate file?")}
{t(
"admin.settings.premium.licenseKey.toggle",
"Got a license key or certificate file?",
)}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.licenseKey.info",
@@ -114,8 +151,13 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<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")}
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}>
@@ -142,22 +184,46 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert variant="light" color="green" icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}>
<Alert
variant="light"
color="green"
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("admin.settings.premium.currentLicense.title", "Active License")}
{t(
"admin.settings.premium.currentLicense.title",
"Active License",
)}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith("file:")
? t("admin.settings.premium.currentLicense.file", "Source: License file ({{path}})", {
path: currentLicenseInfo.licenseKey.substring(5),
})
: t("admin.settings.premium.currentLicense.key", "Source: License key")}
? 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,
})}
{t(
"admin.settings.premium.currentLicense.type",
"Type: {{type}}",
{
type: currentLicenseInfo.licenseType,
},
)}
</Text>
</Stack>
</Alert>
@@ -174,11 +240,17 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
}}
data={[
{
label: t("admin.settings.premium.inputMethod.text", "License Key"),
label: t(
"admin.settings.premium.inputMethod.text",
"License Key",
),
value: "text",
},
{
label: t("admin.settings.premium.inputMethod.file", "Certificate File"),
label: t(
"admin.settings.premium.inputMethod.file",
"Certificate File",
),
value: "file",
},
]}
@@ -198,7 +270,10 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={currentLicenseInfo?.licenseKey || "00000000-0000-0000-0000-000000000000"}
placeholder={
currentLicenseInfo?.licenseKey ||
"00000000-0000-0000-0000-000000000000"
}
type="password"
disabled={!loginEnabled || savingLicense}
/>
@@ -206,29 +281,54 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.premium.file.label", "License Certificate File")}
{t(
"admin.settings.premium.file.label",
"License Certificate File",
)}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t("admin.settings.premium.file.description", "Upload your .lic or .cert license file")}
{t(
"admin.settings.premium.file.description",
"Upload your .lic or .cert license file",
)}
</Text>
<FileButton onChange={setLicenseFile} accept=".lic,.cert" disabled={!loginEnabled || savingLicense}>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
leftSection={
<LocalIcon
icon="upload-file-rounded"
width="1rem"
height="1rem"
/>
}
disabled={!loginEnabled || savingLicense}
>
{licenseFile ? licenseFile.name : t("admin.settings.premium.file.choose", "Choose License File")}
{licenseFile
? licenseFile.name
: t(
"admin.settings.premium.file.choose",
"Choose License File",
)}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t("admin.settings.premium.file.selected", "Selected: {{filename}} ({{size}})", {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + " KB",
})}
{t(
"admin.settings.premium.file.selected",
"Selected: {{filename}} ({{size}})",
{
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + " KB",
},
)}
</Text>
)}
</div>
@@ -37,8 +37,19 @@ const PlanCard: React.FC<PlanCardProps> = ({
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")} />}
<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">
@@ -47,7 +58,12 @@ const PlanCard: React.FC<PlanCardProps> = ({
<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")} />
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t("plan.free.forever", "Forever free")}
/>
</div>
<Divider />
@@ -63,7 +79,9 @@ const PlanCard: React.FC<PlanCardProps> = ({
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth className="plan-button">
{isCurrentTier ? t("plan.current", "Current Plan") : t("plan.free.included", "Included")}
{isCurrentTier
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
</Stack>
</Card>
@@ -75,19 +93,30 @@ const PlanCard: React.FC<PlanCardProps> = ({
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,
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">
<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="current"
label={t("plan.current", "Current Plan")}
/>
) : planGroup.popular &&
!(planGroup.tier === "server" && currentTier === "enterprise") ? (
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
) : null}
@@ -113,7 +142,8 @@ const PlanCard: React.FC<PlanCardProps> = ({
{t("plan.perSeat", "/seat")}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t("plan.perMonth", "/month")} {t("plan.withServer", "+ Server Plan")}
{t("plan.perMonth", "/month")}{" "}
{t("plan.withServer", "+ Server Plan")}
</Text>
</>
) : (
@@ -141,11 +171,16 @@ const PlanCard: React.FC<PlanCardProps> = ({
<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>
)}
{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
@@ -157,8 +192,14 @@ const PlanCard: React.FC<PlanCardProps> = ({
<Button
variant="filled"
fullWidth
onClick={() => (isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup))}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
onClick={() =>
isCurrentTier && onManageClick
? onManageClick()
: onUpgradeClick(planGroup)
}
disabled={
!loginEnabled || isDowngrade || isEnterpriseBlockedForFree
}
className="plan-button"
>
{isCurrentTier
@@ -1,11 +1,25 @@
import React, { useState } from "react";
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from "@mantine/core";
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 {
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";
@@ -21,7 +35,12 @@ interface StaticCheckoutModalProps {
type Stage = "email" | "period-selection" | "license-activation";
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClose, planName, isUpgrade = false }) => {
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
@@ -64,7 +83,10 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key"),
body: t(
"admin.settings.premium.noInput",
"Please provide a license key",
),
});
return;
}
@@ -82,13 +104,18 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
alert({
alertType: "success",
title: t("success", "Success"),
body: t("admin.settings.premium.key.successMessage", "License key activated successfully"),
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"),
body:
response.error ||
t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
@@ -147,7 +174,14 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
const renderContent = () => {
switch (stage) {
case "email":
return <EmailStage emailInput={email} setEmailInput={setEmail} emailError={emailError} onSubmit={handleEmailSubmit} />;
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case "period-selection":
return (
@@ -162,7 +196,11 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("monthly")}
>
<Stack gap="md" style={{ height: "100%", minHeight: "120px" }} justify="space-between">
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.monthly", "Monthly")}
</Text>
@@ -182,7 +220,11 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect("yearly")}
>
<Stack gap="md" style={{ height: "100%", minHeight: "120px" }} justify="space-between">
<Stack
gap="md"
style={{ height: "100%", minHeight: "120px" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
@@ -198,11 +240,23 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
case "license-activation":
return (
<Stack gap="lg" style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}>
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Stack
gap="lg"
style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}
>
<Alert
variant="light"
color="blue"
icon={
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t("plan.static.licenseActivation.checkoutOpened", "Checkout Opened in New Tab")}
{t(
"plan.static.licenseActivation.checkoutOpened",
"Checkout Opened in New Tab",
)}
</Text>
<Text size="sm">
{t(
@@ -217,8 +271,17 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t("plan.static.licenseActivation.success", "License Activated!")}
icon={
<LocalIcon
icon="check-circle-rounded"
width="1rem"
height="1rem"
/>
}
title={t(
"plan.static.licenseActivation.success",
"License Activated!",
)}
>
<Text size="sm">
{t(
@@ -230,12 +293,18 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t("plan.static.licenseActivation.enterKey", "Enter your license key below to activate your plan:")}
{t(
"plan.static.licenseActivation.enterKey",
"Enter your license key below to activate your plan:",
)}
</Text>
<TextInput
label={t("admin.settings.premium.key.label", "License Key")}
description={t("plan.static.licenseActivation.keyDescription", "Paste the license key from your email")}
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"
@@ -244,11 +313,25 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
/>
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t("plan.static.licenseActivation.doLater", "I'll do this later")}
<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
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t(
"plan.static.licenseActivation.activate",
"Activate License",
)}
</Button>
</Group>
</Stack>
@@ -256,7 +339,9 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>{t("common.close", "Close")}</Button>
<Button onClick={handleClose}>
{t("common.close", "Close")}
</Button>
</Group>
)}
</Stack>
@@ -276,7 +361,12 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({ opened, onClo
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon variant="subtle" size="lg" onClick={handleGoBack} aria-label={t("common.back", "Back")}>
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
@@ -1,5 +1,13 @@
import React, { useState } from "react";
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from "@mantine/core";
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";
@@ -20,16 +28,23 @@ interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
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 [selectedPlan, setSelectedPlan] = useState<"server" | "enterprise">(
"server",
);
const [isUpgrade, setIsUpgrade] = useState(false);
const handleOpenCheckout = (plan: "server" | "enterprise", upgrade: boolean) => {
const handleOpenCheckout = (
plan: "server" | "enterprise",
upgrade: boolean,
) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === "free" && plan === "enterprise") {
@@ -53,7 +68,10 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// Show warning about email verification
alert({
alertType: "warning",
title: t("plan.static.billingPortal.title", "Email Verification Required"),
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.",
@@ -110,7 +128,13 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Available Plans */}
<div>
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
@@ -120,7 +144,10 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
fontSize: "0.875rem",
}}
>
{t("plan.static.contactToUpgrade", "Contact us to upgrade or customize your plan")}
{t(
"plan.static.contactToUpgrade",
"Contact us to upgrade or customize your plan",
)}
</p>
<div
@@ -140,9 +167,17 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && <PricingBadge type="current" label={t("plan.current", "Current Plan")} />}
{plan.id === currentPlan.id && (
<PricingBadge
type="current"
label={t("plan.current", "Current Plan")}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
<PricingBadge
type="popular"
label={t("plan.popular", "Popular")}
/>
)}
<Stack gap="md" style={{ height: "100%" }}>
@@ -169,15 +204,27 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
const currentTier = mapLicenseToTier(
currentLicenseInfo || null,
);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(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
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
</Button>
);
}
@@ -198,14 +245,24 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
}
if (isCurrent) {
return (
<Button variant="filled" fullWidth onClick={handleManageBilling} className="plan-button">
<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">
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t("plan.free.included", "Included")}
</Button>
);
@@ -216,9 +273,24 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
if (plan.id === "enterprise") {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip label={t("plan.enterprise.requiresServer", "Requires Server plan")} position="top" withArrow>
<Button variant="filled" disabled fullWidth className="plan-button">
{t("plan.enterprise.requiresServer", "Requires Server")}
<Tooltip
label={t(
"plan.enterprise.requiresServer",
"Requires Server plan",
)}
position="top"
withArrow
>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t(
"plan.enterprise.requiresServer",
"Requires Server",
)}
</Button>
</Tooltip>
);
@@ -236,14 +308,24 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// </Button>
// );
return (
<Button variant="filled" fullWidth disabled className="plan-button">
<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">
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t("plan.manage", "Manage")}
</Button>
);
@@ -259,7 +341,10 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
{/* Feature Comparison Toggle */}
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
@@ -73,7 +73,10 @@ interface UsageAnalyticsChartProps {
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 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,
@@ -1,5 +1,15 @@
import React from "react";
import { Card, Text, Stack, Table, TableThead, TableTbody, TableTr, TableTh, TableTd } from "@mantine/core";
import {
Card,
Text,
Stack,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EndpointStatistic } from "@app/services/usageAnalyticsService";
@@ -30,16 +40,46 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
>
<TableThead>
<TableTr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<TableTh style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="5%">
<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%">
<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">
<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">
<TableTh
style={{
fontWeight: 600,
color: "var(--mantine-color-gray-7)",
}}
fz="sm"
w="20%"
ta="right"
>
{t("usage.table.percentage", "Percentage")}
</TableTh>
</TableTr>
@@ -9,11 +9,17 @@
.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));
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));
color: rgb(
var(--text-divider-label-rgb, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
font-size: 0.75rem; /* 12px */
white-space: nowrap;
}
@@ -35,11 +41,17 @@
/* 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));
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));
color: rgb(
var(--text-divider-label-rgb-light, var(--gray-400)) /
var(--text-divider-opacity, 1)
);
}
.text-divider.subcategory.force-light .text-divider__rule {
@@ -13,7 +13,10 @@ export type LoginCarouselSlide = {
tiltMaxDeg?: number;
};
export const buildLoginSlides = (variant: LogoVariant | null | undefined, t: TFunction): LoginCarouselSlide[] => {
export const buildLoginSlides = (
variant: LogoVariant | null | undefined,
t: TFunction,
): LoginCarouselSlide[] => {
const folder = getLogoFolder(variant);
const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
@@ -21,7 +24,10 @@ export const buildLoginSlides = (variant: LogoVariant | null | undefined, t: TFu
{
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."),
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.",
@@ -32,7 +38,10 @@ export const buildLoginSlides = (variant: LogoVariant | null | undefined, t: TFu
{
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"),
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.",
@@ -43,8 +52,14 @@ export const buildLoginSlides = (variant: LogoVariant | null | undefined, t: TFu
{
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."),
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,
},
@@ -6,7 +6,10 @@ 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 {
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";
@@ -31,7 +34,8 @@ if (!STRIPE_KEY) {
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)}...`,
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`,
);
}
@@ -91,7 +95,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState((prev) => ({ ...prev, email: checkoutState.emailInput }));
checkoutState.setState((prev) => ({
...prev,
email: checkoutState.emailInput,
}));
navigation.goToStage("plan-selection");
} else {
checkoutState.setEmailError(validation.error);
@@ -160,7 +167,10 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// 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 });
checkoutState.setState({
currentStage: "plan-selection",
loading: false,
});
} else {
// No valid premium license - start at email stage
checkoutState.setState({ currentStage: "email", loading: false });
@@ -184,10 +194,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (checkoutState.state.currentStage === "payment" && !checkoutState.state.clientSecret && !checkoutState.state.loading) {
if (
checkoutState.state.currentStage === "payment" &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
session.createCheckoutSession();
}
}, [checkoutState.state.currentStage, checkoutState.state.clientSecret, checkoutState.state.loading, session]);
}, [
checkoutState.state.currentStage,
checkoutState.state.clientSecret,
checkoutState.state.loading,
session,
]);
// Render stage content
const renderContent = () => {
@@ -234,7 +253,12 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
);
case "error":
return <ErrorStage error={checkoutState.state.error || "An unknown error occurred"} onClose={handleClose} />;
return (
<ErrorStage
error={checkoutState.state.error || "An unknown error occurred"}
onClose={handleClose}
/>
);
default:
return null;
@@ -250,7 +274,12 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon variant="subtle" size="lg" onClick={navigation.goBack} aria-label={t("common.back", "Back")}>
<ActionIcon
variant="subtle"
size="lg"
onClick={navigation.goBack}
aria-label={t("common.back", "Back")}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
@@ -40,7 +40,15 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
}
// Enterprise mode
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = "md" } = props;
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";
@@ -75,7 +83,11 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size="sm" c="dimmed" mb="xs">
Total ({seatCount} seats)
</Text>
<Text size={totalFontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
<Text
size={totalFontSize}
fw={PRICE_FONT_WEIGHT}
style={{ lineHeight: 1 }}
>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{" "}
@@ -1,5 +1,8 @@
import { useCallback } from "react";
import { CheckoutState, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
import {
CheckoutState,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Stage navigation and history management hook
@@ -1,7 +1,10 @@
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";
import {
CheckoutState,
PollingStatus,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Checkout session creation and payment handling hook
@@ -19,7 +22,12 @@ export const useCheckoutSession = (
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => void,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const createCheckoutSession = useCallback(async () => {
if (!selectedPlan) {
@@ -46,13 +54,20 @@ export const useCheckoutSession = (
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL" && licenseInfo.licenseKey) {
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);
console.warn(
"Could not fetch license info, proceeding as new license:",
error,
);
}
const response = await licenseService.createCheckoutSession({
@@ -80,7 +95,10 @@ export const useCheckoutSession = (
loading: false,
}));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
const errorMessage =
err instanceof Error
? err.message
: "Failed to create checkout session";
setState({
currentStage: "error",
error: errorMessage,
@@ -88,7 +106,16 @@ export const useCheckoutSession = (
});
onError?.(errorMessage);
}
}, [selectedPlan, state.email, installationId, minimumSeats, setState, setInstallationId, setCurrentLicenseKey, onError]);
}, [
selectedPlan,
state.email,
installationId,
minimumSeats,
setState,
setInstallationId,
setCurrentLicenseKey,
onError,
]);
const handlePaymentComplete = useCallback(async () => {
// Preserve state when changing stage
@@ -1,6 +1,10 @@
import { useState, useCallback, useRef } from "react";
import { PlanTierGroup } from "@app/services/licenseService";
import { CheckoutState, PollingStatus, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
import {
CheckoutState,
PollingStatus,
CheckoutStage,
} from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Centralized state management hook for checkout flow
@@ -13,9 +17,13 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
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 [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
planGroup.yearly ? "yearly" : "monthly",
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
null,
);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
@@ -24,7 +32,8 @@ export const useCheckoutState = (planGroup: PlanTierGroup) => {
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const selectedPlan =
selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const resetState = useCallback(() => {
setState({
@@ -1,5 +1,8 @@
import { useCallback } from "react";
import { pollLicenseKeyWithBackoff, activateLicenseKey } from "@app/utils/licenseCheckoutUtils";
import {
pollLicenseKeyWithBackoff,
activateLicenseKey,
} from "@app/utils/licenseCheckoutUtils";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
@@ -9,7 +12,12 @@ 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,
onLicenseActivated?: (licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => void,
) => {
const pollForLicenseKey = useCallback(
async (installId: string) => {
@@ -9,13 +9,24 @@ interface EmailStageProps {
onSubmit: () => void;
}
export const EmailStage: React.FC<EmailStageProps> = ({ emailInput, setEmailInput, emailError, onSubmit }) => {
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" }}>
<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.")}
{t(
"payment.emailStage.description",
"We'll use this to send your license key and receipts.",
)}
</Text>
<TextInput
@@ -2,7 +2,10 @@ 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 {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
@@ -15,7 +18,11 @@ interface PaymentStageProps {
onPaymentComplete: () => void;
}
export const PaymentStage: React.FC<PaymentStageProps> = ({ clientSecret, selectedPlan, onPaymentComplete }) => {
export const PaymentStage: React.FC<PaymentStageProps> = ({
clientSecret,
selectedPlan,
onPaymentComplete,
}) => {
const { t } = useTranslation();
// Show loading while creating checkout session
@@ -1,5 +1,13 @@
import React from "react";
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from "@mantine/core";
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";
@@ -19,7 +27,12 @@ interface PlanSelectionStageProps {
onSelectPlan: (period: "monthly" | "yearly") => void;
}
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGroup, minimumSeats, savings, onSelectPlan }) => {
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
onSelectPlan,
}) => {
const { t } = useTranslation();
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
@@ -30,8 +43,18 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
{/* 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">
<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>
@@ -43,7 +66,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
mode="enterprise"
basePrice={planGroup.monthly.price}
seatPrice={planGroup.monthly.seatPrice}
totalPrice={calculateTotalWithSeats(planGroup.monthly.price, planGroup.monthly.seatPrice, seatCount)}
totalPrice={calculateTotalWithSeats(
planGroup.monthly.price,
planGroup.monthly.seatPrice,
seatCount,
)}
currency={planGroup.monthly.currency}
period="month"
seatCount={seatCount}
@@ -82,11 +109,19 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
{savings && (
<PricingBadge
type="savings"
label={t("payment.planStage.savePercent", "Save {{percent}}%", { percent: savings.percent })}
label={t(
"payment.planStage.savePercent",
"Save {{percent}}%",
{ percent: savings.percent },
)}
/>
)}
<Stack gap="md" style={{ height: "100%" }} justify="space-between">
<Stack
gap="md"
style={{ height: "100%" }}
justify="space-between"
>
<Text size="lg" fw={600}>
{t("payment.yearly", "Yearly")}
</Text>
@@ -100,7 +135,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
basePrice={planGroup.yearly.price}
seatPrice={planGroup.yearly.seatPrice}
totalPrice={calculateMonthlyEquivalent(
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount),
calculateTotalWithSeats(
planGroup.yearly.price,
planGroup.yearly.seatPrice,
seatCount,
),
)}
currency={planGroup.yearly.currency}
period="year"
@@ -108,28 +147,40 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
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,
),
})}
{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)}
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),
})}
{t(
"payment.planStage.billedYearly",
"Billed yearly at {{currency}}{{amount}}",
{
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2),
},
)}
</Text>
</Stack>
)}
@@ -137,9 +188,13 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({ planGrou
{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),
})}
{t(
"payment.planStage.savingsAmount",
"You save {{amount}}",
{
amount: formatPrice(savings.amount, savings.currency),
},
)}
</Text>
</Alert>
)}
@@ -1,5 +1,14 @@
import React from "react";
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from "@mantine/core";
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";
@@ -10,13 +19,23 @@ interface SuccessStageProps {
onClose: () => void;
}
export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, currentLicenseKey, licenseKey, onClose }) => {
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>
<Text size="sm">
{t(
"payment.successMessage",
"Your subscription has been activated successfully.",
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === "polling" && (
@@ -24,8 +43,14 @@ export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, curre
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t("payment.syncingLicense", "Syncing your upgraded license...")
: t("payment.generatingLicense", "Generating your license key...")}
? t(
"payment.syncingLicense",
"Syncing your upgraded license...",
)
: t(
"payment.generatingLicense",
"Generating your license key...",
)}
</Text>
</Group>
)}
@@ -37,7 +62,11 @@ export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, curre
{t("payment.licenseKey", "Your License Key")}
</Text>
<Code block>{licenseKey}</Code>
<Button variant="light" size="sm" onClick={() => navigator.clipboard.writeText(licenseKey)}>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t("common.copy", "Copy to Clipboard")}
</Button>
<Text size="xs" c="dimmed">
@@ -51,7 +80,10 @@ export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, curre
)}
{pollingStatus === "ready" && currentLicenseKey && (
<Alert color="green" title={t("payment.upgradeComplete", "Upgrade Complete")}>
<Alert
color="green"
title={t("payment.upgradeComplete", "Upgrade Complete")}
>
<Text size="sm">
{t(
"payment.upgradeCompleteMessage",
@@ -62,7 +94,10 @@ export const SuccessStage: React.FC<SuccessStageProps> = ({ pollingStatus, curre
)}
{pollingStatus === "timeout" && (
<Alert color="yellow" title={t("payment.licenseDelayed", "License Key Processing")}>
<Alert
color="yellow"
title={t("payment.licenseDelayed", "License Key Processing")}
>
<Text size="sm">
{t(
"payment.licenseDelayedMessage",
@@ -7,14 +7,24 @@ export interface StripeCheckoutProps {
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => 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 CheckoutStage =
| "email"
| "plan-selection"
| "payment"
| "success"
| "error";
export type CheckoutState = {
currentStage: CheckoutStage;
@@ -20,7 +20,9 @@ export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
/**
* Get base card style
*/
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
export function getBaseCardStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
position: "relative",
display: "flex",
@@ -33,7 +35,9 @@ export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties
/**
* Get clickable paper style
*/
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
export function getClickablePaperStyle(
isHighlighted: boolean = false,
): CSSProperties {
return {
cursor: "pointer",
transition: "all 0.2s",
@@ -4,7 +4,9 @@ import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/check
/**
* Validate email address format
*/
export const validateEmail = (email: string): { valid: boolean; error: string } => {
export const validateEmail = (
email: string,
): { valid: boolean; error: string } => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return {
@@ -18,14 +20,28 @@ export const validateEmail = (email: string): { valid: boolean; error: string }
/**
* Get dynamic modal title based on current stage
*/
export const getModalTitle = (stage: CheckoutStage, planName: string, t: TFunction): string => {
export const getModalTitle = (
stage: CheckoutStage,
planName: string,
t: TFunction,
): string => {
switch (stage) {
case "email":
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", { planName });
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", {
planName,
});
case "plan-selection":
return t("payment.planStage.modalTitle", "Select Billing Period - {{planName}}", { planName });
return t(
"payment.planStage.modalTitle",
"Select Billing Period - {{planName}}",
{ planName },
);
case "payment":
return t("payment.paymentStage.modalTitle", "Complete Payment - {{planName}}", { planName });
return t(
"payment.paymentStage.modalTitle",
"Complete Payment - {{planName}}",
{ planName },
);
case "success":
return t("payment.success", "Payment Successful!");
case "error":
@@ -18,7 +18,11 @@ export function calculateMonthlyEquivalent(yearlyPrice: number): number {
/**
* Calculate total price including seats
*/
export function calculateTotalWithSeats(basePrice: number, seatPrice: number | undefined, seatCount: number): number {
export function calculateTotalWithSeats(
basePrice: number,
seatPrice: number | undefined,
seatCount: number,
): number {
if (seatPrice === undefined) return basePrice;
return basePrice + seatPrice * seatCount;
}
@@ -26,7 +30,11 @@ export function calculateTotalWithSeats(basePrice: number, seatPrice: number | u
/**
* Format price with currency symbol
*/
export function formatPrice(amount: number, currency: string, decimals: number = 2): string {
export function formatPrice(
amount: number,
currency: string,
decimals: number = 2,
): string {
return `${currency}${amount.toFixed(decimals)}`;
}
@@ -50,7 +58,9 @@ export function calculateDisplayPricing(
// Use yearly price divided by 12 for best value display
return {
displayPrice: calculateMonthlyEquivalent(yearly.price),
displaySeatPrice: yearly.seatPrice ? calculateMonthlyEquivalent(yearly.seatPrice) : undefined,
displaySeatPrice: yearly.seatPrice
? calculateMonthlyEquivalent(yearly.seatPrice)
: undefined,
displayCurrency: yearly.currency,
};
}
@@ -5,7 +5,10 @@ import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/
* 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 => {
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number,
): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === "enterprise";
@@ -14,10 +17,16 @@ export const calculateSavings = (planGroup: PlanTierGroup, minimumSeats: number)
let monthlyAnnual: number;
let yearlyTotal: number;
if (isEnterprise && planGroup.monthly.seatPrice && planGroup.yearly.seatPrice) {
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;
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;
@@ -1,6 +1,18 @@
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 {
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";
@@ -13,7 +25,15 @@ interface ParticipantViewProps {
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const { t } = useTranslation();
const { session, participant, loading, error, submitSignature, decline, downloadDocument } = useParticipantSession(token);
const {
session,
participant,
loading,
error,
submitSignature,
decline,
downloadDocument,
} = useParticipantSession(token);
const [certType, setCertType] = useState<string>("P12");
const [password, setPassword] = useState<string>("");
@@ -24,7 +44,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
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);
const [notification, setNotification] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
type CertValidationState =
| { status: "idle" }
@@ -32,7 +55,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
| { status: "valid"; notAfter: string | null; subjectName: string | null }
| { status: "error"; message: string };
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: "idle" });
const [certValidation, setCertValidation] = useState<CertValidationState>({
status: "idle",
});
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -56,24 +81,39 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
formData.append("p12File", certFile);
}
const res = await fetch("/api/v1/workflow/participant/validate-certificate", {
method: "POST",
body: formData,
});
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 });
setCertValidation({
status: "valid",
notAfter: data.notAfter,
subjectName: data.subjectName,
});
} else {
setCertValidation({
status: "error",
message: data.error ?? t("certSign.collab.participant.certInvalidFallback", "Invalid certificate"),
message:
data.error ??
t(
"certSign.collab.participant.certInvalidFallback",
"Invalid certificate",
),
});
}
} catch {
setCertValidation({
status: "error",
message: t("certSign.collab.participant.certNetworkError", "Could not validate certificate"),
message: t(
"certSign.collab.participant.certNetworkError",
"Could not validate certificate",
),
});
}
}, 600);
@@ -85,7 +125,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const handleSubmitSignature = async () => {
if (!certFile && certType !== "SERVER") {
setNotification({ type: "error", message: "Please select a certificate file" });
setNotification({
type: "error",
message: "Please select a certificate file",
});
return;
}
@@ -104,7 +147,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
reason,
showLogo: true,
});
setNotification({ type: "success", message: "Signature submitted successfully!" });
setNotification({
type: "success",
message: "Signature submitted successfully!",
});
} catch (err: unknown) {
setNotification({
type: "error",
@@ -116,13 +162,21 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
};
const handleDecline = async () => {
if (window.confirm("Are you sure you want to decline signing this document?")) {
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." });
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)}` });
setNotification({
type: "error",
message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
};
@@ -169,13 +223,22 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
}
};
const canSign = !participant.hasCompleted && !participant.isExpired && session.status === "IN_PROGRESS";
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" />}
icon={
notification.type === "success" ? (
<CheckCircleIcon fontSize="small" />
) : (
<InfoIcon fontSize="small" />
)
}
color={notification.type === "success" ? "green" : "red"}
withCloseButton
onClose={() => setNotification(null)}
@@ -198,7 +261,11 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
</Group>
{session.message && (
<Alert icon={<InfoIcon fontSize="small" />} color="blue" variant="light">
<Alert
icon={<InfoIcon fontSize="small" />}
color="blue"
variant="light"
>
<Text size="sm">{session.message}</Text>
</Alert>
)}
@@ -265,24 +332,54 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{/* Certificate validation feedback */}
{certValidation.status === "validating" && (
<Text size="sm" c="dimmed" data-testid="cert-validation-feedback">
{t("certSign.collab.participant.certValidating", "Validating certificate...")}
<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")}
<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(),
})
? t(
"certSign.collab.participant.certValidUntil",
" until {{date}}",
{
date: new Date(
certValidation.notAfter,
).toLocaleDateString(),
},
)
: ""}
{certValidation.subjectName
? ` · ${certValidation.subjectName}`
: ""}
{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
size="sm"
c="red"
data-testid="cert-validation-feedback"
>
{t(
"certSign.collab.participant.certInvalid",
"✗ {{error}}",
{ error: certValidation.message },
)}
</Text>
)}
</>
@@ -308,7 +405,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
label="Page Number (optional)"
type="number"
value={pageNumber}
onChange={(e) => setPageNumber(parseInt(e.currentTarget.value) || 1)}
onChange={(e) =>
setPageNumber(parseInt(e.currentTarget.value) || 1)
}
size="sm"
min={1}
/>
@@ -318,7 +417,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
leftSection={<CheckCircleIcon fontSize="small" />}
onClick={handleSubmitSignature}
loading={isSubmitting}
disabled={isSubmitting || certValidation.status === "validating"}
disabled={
isSubmitting || certValidation.status === "validating"
}
color="green"
data-testid="submit-signature-button"
>
@@ -341,7 +442,8 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
{participant.hasCompleted && (
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
You have {participant.status === "SIGNED" ? "signed" : "declined"} this document.
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
this document.
</Alert>
)}
@@ -49,7 +49,10 @@ export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
* @param email - The email address to prefill
* @returns The complete URL with encoded email parameter
*/
export function buildStripeUrlWithEmail(baseUrl: string, email: string): string {
export function buildStripeUrlWithEmail(
baseUrl: string,
email: string,
): string {
const encodedEmail = encodeURIComponent(email);
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
}
@@ -1,10 +1,26 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
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 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 {
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";
@@ -17,25 +33,34 @@ export interface CheckoutOptions {
}
interface CheckoutContextValue {
openCheckout: (tier: "server" | "enterprise", options?: CheckoutOptions) => Promise<void>;
openCheckout: (
tier: "server" | "enterprise",
options?: CheckoutOptions,
) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | undefined>(undefined);
const CheckoutContext = createContext<CheckoutContextValue | undefined>(
undefined,
);
interface CheckoutProviderProps {
children: ReactNode;
defaultCurrency?: string;
}
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, defaultCurrency }) => {
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 [selectedPlanGroup, setSelectedPlanGroup] =
useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(() => {
// Use provided default or auto-detect from locale
@@ -108,7 +133,9 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
const activation = await resyncExistingLicense();
if (activation.success) {
console.log("License synced successfully, refreshing license context");
console.log(
"License synced successfully, refreshing license context",
);
// Ensure plans are loaded before using them
if (!plansLoaded) {
@@ -120,7 +147,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
const tier =
activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find((pg) => pg.tier === tier);
@@ -137,7 +165,10 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
});
}
} else {
console.error("Failed to sync license after upgrade:", activation.error);
console.error(
"Failed to sync license after upgrade:",
activation.error,
);
alert({
alertType: "error",
title: t("payment.syncError"),
@@ -149,7 +180,10 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
try {
const installationId = await licenseService.getInstallationId();
console.log("Polling for license key with installation ID:", installationId);
console.log(
"Polling for license key with installation ID:",
installationId,
);
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installationId);
@@ -171,7 +205,10 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
const tier =
activation.licenseType === "ENTERPRISE"
? "enterprise"
: "server";
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find((pg) => pg.tier === tier);
@@ -232,7 +269,15 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
};
handleCheckoutReturn();
}, [t, refetchPlans, refetchLicense, plans, fetchPlansIfNeeded, plansLoaded, currentCurrency]);
}, [
t,
refetchPlans,
refetchLicense,
plans,
fetchPlansIfNeeded,
plansLoaded,
currentCurrency,
]);
const openCheckout = useCallback(
async (tier: "server" | "enterprise", options: CheckoutOptions = {}) => {
@@ -241,7 +286,9 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
// Check if Supabase is configured
if (!isSupabaseConfigured) {
throw new Error("Checkout is not available. Supabase is not configured.");
throw new Error(
"Checkout is not available. Supabase is not configured.",
);
}
// Update currency if provided
@@ -268,7 +315,10 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
licenseInfo = licenseData;
totalUsers = userData.totalUsers || 0;
} catch (err) {
console.warn("Could not fetch license/user info, proceeding with defaults:", err);
console.warn(
"Could not fetch license/user info, proceeding with defaults:",
err,
);
}
// Calculate minimum seats for enterprise upgrades
@@ -281,12 +331,16 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
// 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}`);
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}`);
console.log(
`Setting minimum seats from current license: ${calculatedMinSeats}`,
);
}
}
@@ -304,7 +358,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to open checkout";
const errorMessage =
err instanceof Error ? err.message : "Failed to open checkout";
console.error("Error opening checkout:", errorMessage);
options.onError?.(errorMessage);
} finally {
@@ -343,7 +398,12 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({ children, de
);
const handleLicenseActivated = useCallback(
(licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => {
(licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log("License activated:", licenseInfo);
// Could expose this via context if needed
},
@@ -1,4 +1,13 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from "react";
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";
@@ -11,13 +20,17 @@ interface LicenseContextValue {
refetchLicense: () => Promise<void>;
}
const LicenseContext = createContext<LicenseContextValue | undefined>(undefined);
const LicenseContext = createContext<LicenseContextValue | undefined>(
undefined,
);
interface LicenseProviderProps {
children: ReactNode;
}
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
export const LicenseProvider: React.FC<LicenseProviderProps> = ({
children,
}) => {
const { config } = useAppConfig();
const location = useLocation();
const configRef = useRef(config);
@@ -52,7 +65,9 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
// Only fetch license info if user is an admin
if (!currentConfig.isAdmin) {
console.log("[LicenseContext] User is not an admin, skipping license fetch");
console.log(
"[LicenseContext] User is not an admin, skipping license fetch",
);
setLoading(false);
return;
}
@@ -73,7 +88,8 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
const info = await licenseService.getLicenseInfo();
setLicenseInfo(info);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch license info";
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch license info";
console.error("Error fetching license info:", errorMessage);
setError(errorMessage);
setLicenseInfo(null);
@@ -114,7 +130,11 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
[licenseInfo, loading, error, refetchLicense],
);
return <LicenseContext.Provider value={contextValue}>{children}</LicenseContext.Provider>;
return (
<LicenseContext.Provider value={contextValue}>
{children}
</LicenseContext.Provider>
);
};
export const useOptionalLicense = (): LicenseContextValue | undefined => {
@@ -1,10 +1,21 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
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";
import {
getSimulatedAdminUsage,
getSimulatedWauResponse,
} from "@app/testing/serverExperienceSimulations";
const SELF_REPORTED_ADMIN_KEY = "stirling-self-reported-admin";
const FREE_TIER_LIMIT = 5;
@@ -68,7 +79,9 @@ export interface ServerExperienceValue {
scenarioKey: ServerScenarioKey;
}
const ServerExperienceContext = createContext<ServerExperienceValue | undefined>(undefined);
const ServerExperienceContext = createContext<
ServerExperienceValue | undefined
>(undefined);
function getStoredSelfReportedAdmin(): boolean {
if (typeof window === "undefined") {
@@ -85,7 +98,10 @@ function getErrorMessage(error: unknown): string {
if (typeof error === "string") {
return error;
}
if (isAxiosError(error) && typeof error.response?.data?.message === "string") {
if (
isAxiosError(error) &&
typeof error.response?.data?.message === "string"
) {
return error.response.data.message;
}
if (error instanceof Error) {
@@ -94,12 +110,18 @@ function getErrorMessage(error: unknown): string {
return "Unable to load server usage";
}
export function ServerExperienceProvider({ children }: { children: ReactNode }) {
export function ServerExperienceProvider({
children,
}: {
children: ReactNode;
}) {
const { config } = useAppConfig();
const { user } = useAuth();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(getStoredSelfReportedAdmin);
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(
getStoredSelfReportedAdmin,
);
const [userCountState, setUserCountState] = useState<UserCountState>({
totalUsers: null,
weeklyActiveUsers: null,
@@ -111,7 +133,8 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const effectiveIsAdmin =
configIsAdmin || (!loginEnabled && selfReportedAdmin);
const isAuthenticated = Boolean(user);
const setSelfReportedAdmin = useCallback((value: boolean) => {
@@ -179,11 +202,17 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
const responseData =
testResponse ??
(
await apiClient.get<{ totalUsers?: number }>("/api/v1/proprietary/ui-data/admin-settings", {
suppressErrorToast: true,
})
await apiClient.get<{ totalUsers?: number }>(
"/api/v1/proprietary/ui-data/admin-settings",
{
suppressErrorToast: true,
},
)
).data;
const totalUsers = typeof responseData?.totalUsers === "number" ? responseData.totalUsers : null;
const totalUsers =
typeof responseData?.totalUsers === "number"
? responseData.totalUsers
: null;
setUserCountState({
totalUsers,
weeklyActiveUsers: null,
@@ -204,7 +233,10 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
suppressErrorToast: true,
})
).data;
const weeklyActiveUsers = typeof responseData?.weeklyActiveUsers === "number" ? responseData.weeklyActiveUsers : null;
const weeklyActiveUsers =
typeof responseData?.weeklyActiveUsers === "number"
? responseData.weeklyActiveUsers
: null;
setUserCountState({
totalUsers: weeklyActiveUsers,
weeklyActiveUsers,
@@ -235,7 +267,11 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
}, [fetchUserCounts]);
const hasPaidLicense = useMemo(() => {
return config?.license === "SERVER" || config?.license === "PRO" || config?.license === "ENTERPRISE";
return (
config?.license === "SERVER" ||
config?.license === "PRO" ||
config?.license === "ENTERPRISE"
);
}, [config?.license]);
const licenseKeyValid = useMemo(() => {
@@ -256,7 +292,9 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
}, [userCountState.totalUsers]);
const userCountResolved =
!userCountState.loading && userCountState.source !== "unknown" && userCountState.totalUsers !== null;
!userCountState.loading &&
userCountState.source !== "unknown" &&
userCountState.totalUsers !== null;
const scenarioKey = useMemo<ServerScenarioKey>(() => {
if (hasPaidLicense) {
@@ -269,17 +307,32 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
if (!loginEnabled) {
if (selfReportedAdmin) {
return overLimit ? "no-login-admin-over-limit-no-license" : "no-login-admin-under-limit-no-license";
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";
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-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]);
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,
@@ -310,13 +363,19 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
scenarioKey,
};
return <ServerExperienceContext.Provider value={value}>{children}</ServerExperienceContext.Provider>;
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");
throw new Error(
"useServerExperience must be used within ServerExperienceProvider",
);
}
return context;
}
@@ -1,4 +1,11 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
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";
@@ -20,13 +27,17 @@ interface UpdateSeatsContextValue {
isLoading: boolean;
}
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(undefined);
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(
undefined,
);
interface UpdateSeatsProviderProps {
children: ReactNode;
}
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ children }) => {
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
@@ -49,7 +60,9 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
location.pathname.startsWith("/invite/");
if (isAuthRoute) {
console.log("[UpdateSeatsContext] On auth route, skipping billing return check");
console.log(
"[UpdateSeatsContext] On auth route, skipping billing return check",
);
return;
}
@@ -83,9 +96,13 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
alert({
alertType: "success",
title: t("billing.seatsUpdated", "Seats Updated"),
body: t("billing.seatsUpdatedMessage", "Your enterprise seats have been updated to {{seats}}", {
seats: updatedLicense.maxUsers,
}),
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");
@@ -106,7 +123,10 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
// CRITICAL FIX: Properly handle async function and catch errors
handleBillingReturn().catch((error) => {
console.error("[UpdateSeatsContext] Error in billing return handler:", error);
console.error(
"[UpdateSeatsContext] Error in billing return handler:",
error,
);
// Don't throw - this is initialization, should not block rendering
});
}, [t, location.pathname, license]);
@@ -117,11 +137,19 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
setIsLoading(true);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([licenseService.getLicenseInfo(), userManagementService.getUsers()]);
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"));
throw new Error(
t(
"billing.notEnterprise",
"Seat management is only available for enterprise licenses",
),
);
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
@@ -139,7 +167,8 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to open seat update";
const errorMessage =
err instanceof Error ? err.message : "Failed to open seat update";
console.error("Error opening seat update:", errorMessage);
alert({
alertType: "error",
@@ -174,11 +203,15 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
// Call manage-billing function with new seat count
const portalUrl = await licenseService.updateEnterpriseSeats(newSeatCount, licenseInfo.licenseKey);
const portalUrl = await licenseService.updateEnterpriseSeats(
newSeatCount,
licenseInfo.licenseKey,
);
return portalUrl;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to update seats";
const errorMessage =
err instanceof Error ? err.message : "Failed to update seats";
console.error("Error updating seats:", errorMessage);
currentOptions.onError?.(errorMessage);
throw err;
@@ -226,7 +259,9 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
export const useUpdateSeats = (): UpdateSeatsContextValue => {
const context = useContext(UpdateSeatsContext);
if (!context) {
throw new Error("useUpdateSeats must be used within an UpdateSeatsProvider");
throw new Error(
"useUpdateSeats must be used within an UpdateSeatsProvider",
);
}
return context;
};
@@ -10,10 +10,16 @@ interface AccountLogoutDeps {
* Desktop builds override this file via path resolution.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
return async ({
signOut,
redirectToLogin,
}: AccountLogoutDeps): Promise<void> => {
try {
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
window.sessionStorage.setItem(
"stirling_sso_auto_login_logged_out",
"1",
);
}
await signOut();
} finally {
@@ -2,6 +2,8 @@
* Extension hook for platform-specific OAuth navigation.
* Proprietary/web builds default to in-window navigation.
*/
export async function startOAuthNavigation(_redirectUrl: string): Promise<boolean> {
export async function startOAuthNavigation(
_redirectUrl: string,
): Promise<boolean> {
return false;
}
+4 -1
View File
@@ -1,5 +1,8 @@
import { useState, useEffect } from "react";
import licenseService, { PlanTier, PlansResponse } from "@app/services/licenseService";
import licenseService, {
PlanTier,
PlansResponse,
} from "@app/services/licenseService";
export interface UsePlansReturn {
plans: PlanTier[];
@@ -9,5 +9,11 @@ export function useShouldShowWelcomeModal(): boolean {
// 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;
return (
!loading &&
!preferences.hasCompletedOnboarding &&
preferences.toolPanelModePromptSeen &&
!isMobile &&
!!session
);
}
@@ -20,9 +20,13 @@ export interface UseParticipantSessionResult {
/**
* Hook for managing workflow session from participant perspective
*/
export const useParticipantSession = (token?: string): UseParticipantSessionResult => {
export const useParticipantSession = (
token?: string,
): UseParticipantSessionResult => {
const [session, setSession] = useState<WorkflowSessionResponse | null>(null);
const [participant, setParticipant] = useState<ParticipantResponse | null>(null);
const [participant, setParticipant] = useState<ParticipantResponse | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,7 +43,8 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to load session";
: (err instanceof Error ? err.message : undefined) ||
"Failed to load session";
setError(errorMsg);
} finally {
setLoading(false);
@@ -51,7 +56,8 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
setLoading(true);
setError(null);
try {
const updatedParticipant = await workflowService.submitSignature(request);
const updatedParticipant =
await workflowService.submitSignature(request);
setParticipant(updatedParticipant);
// Reload session to get updated status
if (request.participantToken) {
@@ -60,7 +66,8 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to submit signature";
: (err instanceof Error ? err.message : undefined) ||
"Failed to submit signature";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
@@ -75,14 +82,18 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
setLoading(true);
setError(null);
try {
const updatedParticipant = await workflowService.declineParticipation(token, reason);
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";
: (err instanceof Error ? err.message : undefined) ||
"Failed to decline";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
@@ -109,7 +120,8 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to download document";
: (err instanceof Error ? err.message : undefined) ||
"Failed to download document";
setError(errorMsg);
} finally {
setLoading(false);
@@ -5,7 +5,9 @@
justify-content: center;
padding: 50px 20px;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.card {
@@ -67,7 +67,9 @@ describe("AuthCallback", () => {
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "jwt-available" }));
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
// Verify getSession was called to validate token
expect(springAuth.getSession).toHaveBeenCalled();
@@ -129,7 +131,9 @@ describe("AuthCallback", () => {
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(new Error("Network error"));
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error("Network error"),
);
render(
<BrowserRouter>
+102 -32
View File
@@ -29,14 +29,28 @@ export default function AuthCallback() {
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}] ════════════════════════════════════`,
);
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}`);
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`);
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." },
@@ -46,14 +60,20 @@ export default function AuthCallback() {
// 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)`);
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`);
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 '#'
@@ -61,7 +81,9 @@ export default function AuthCallback() {
const token = params.get("access_token");
if (!token) {
console.error(`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`);
console.error(
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
);
navigate("/login", {
replace: true,
state: { error: "OAuth login failed - no token received." },
@@ -69,14 +91,22 @@ export default function AuthCallback() {
return;
}
console.log(`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`);
console.log(`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`);
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}] ✓ JWT stored in localStorage`,
);
console.log(`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`);
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`);
@@ -84,13 +114,18 @@ export default function AuthCallback() {
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(`[AuthCallback:${executionId}] Step 4: Validating token with backend`);
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);
console.error(
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
error,
);
localStorage.removeItem("stirling_jwt");
navigate("/login", {
replace: true,
@@ -99,13 +134,21 @@ export default function AuthCallback() {
return;
}
console.log(`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`);
console.log(`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`);
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`);
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
@@ -114,24 +157,47 @@ export default function AuthCallback() {
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(`[AuthCallback:${executionId}] Step 7: Navigating to home page`);
console.log(
`[AuthCallback:${executionId}] Step 7: Navigating to home page`,
);
// Clear the hash from URL and redirect to home page
navigate("/", { replace: true });
const duration = performance.now() - startTime;
console.log(`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`);
console.log(`[AuthCallback:${executionId}] ════════════════════════════════════`);
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}] ════════════════════════════════════`,
);
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}] ════════════════════════════════════`);
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." },
@@ -147,8 +213,12 @@ export default function AuthCallback() {
<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 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>
);
@@ -2,7 +2,16 @@ 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 {
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";
@@ -35,9 +44,15 @@ export default function InviteAccept() {
// 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)"),
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)"),
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
@@ -55,9 +70,12 @@ export default function InviteAccept() {
const validateToken = async () => {
try {
setLoading(true);
const response = await apiClient.get<InviteData>(`/api/v1/invite/validate/${token}`, {
suppressErrorToast: true,
});
const response = await apiClient.get<InviteData>(
`/api/v1/invite/validate/${token}`,
{
suppressErrorToast: true,
},
);
setInviteData(response.data);
setError(null);
} catch (err: unknown) {
@@ -116,7 +134,8 @@ export default function InviteAccept() {
} 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");
: (err instanceof Error ? err.message : undefined) ||
t("invite.acceptError", "Failed to create account");
setError(errorMessage);
} finally {
setSubmitting(false);
@@ -126,7 +145,9 @@ export default function InviteAccept() {
if (loading) {
return (
<AuthLayout>
<LoginHeader title={t("invite.validating", "Validating invitation...")} />
<LoginHeader
title={t("invite.validating", "Validating invitation...")}
/>
<Center py="xl">
<Loader size="md" />
</Center>
@@ -137,7 +158,9 @@ export default function InviteAccept() {
if (error && !inviteData) {
return (
<AuthLayout>
<LoginHeader title={t("invite.invalidInvitation", "Invalid Invitation")} />
<LoginHeader
title={t("invite.invalidInvitation", "Invalid Invitation")}
/>
<ErrorMessage error={error} />
<div className="auth-section">
<button
@@ -156,20 +179,36 @@ export default function InviteAccept() {
<AuthLayout>
<LoginHeader
title={t("invite.welcomeTitle", "You've been invited!")}
subtitle={t("invite.welcomeSubtitle", "Complete your account setup to get started")}
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)" }}>
<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" }}>
<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{" "}
{t("invite.linkExpires", "Link expires")}:{" "}
{new Date(inviteData.expiresAt).toLocaleDateString()} at{" "}
{new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
@@ -186,7 +225,10 @@ export default function InviteAccept() {
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("invite.emailPlaceholder", "Enter your email address")}
placeholder={t(
"invite.emailPlaceholder",
"Enter your email address",
)}
disabled={submitting}
required
autoComplete="email"
@@ -207,7 +249,10 @@ export default function InviteAccept() {
label={t("invite.confirmPassword", "Confirm password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("invite.confirmPasswordPlaceholder", "Re-enter your password")}
placeholder={t(
"invite.confirmPasswordPlaceholder",
"Re-enter your password",
)}
disabled={submitting}
required
autoComplete="new-password"
@@ -219,7 +264,9 @@ export default function InviteAccept() {
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")}
{submitting
? t("invite.creating", "Creating Account...")
: t("invite.createAccount", "Create Account")}
</button>
</div>
</Stack>
@@ -228,7 +275,12 @@ export default function InviteAccept() {
<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">
<Anchor
component="button"
type="button"
onClick={() => navigate("/login")}
c="dark"
>
{t("invite.signIn", "Sign in")}
</Anchor>
</Text>
+30 -5
View File
@@ -28,7 +28,9 @@ export default function Landing() {
// Debug: Track Landing component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(`[Landing:${mountId}] 🔵 Component mounted at ${location.pathname}`);
console.log(
`[Landing:${mountId}] 🔵 Component mounted at ${location.pathname}`,
);
console.log(`[Landing:${mountId}] Mount state:`, {
authLoading,
configLoading,
@@ -38,7 +40,13 @@ export default function Landing() {
return () => {
console.log(`[Landing:${mountId}] 🔴 Component unmounting`);
};
}, [location.pathname, authLoading, configLoading, backendProbe.loading, session]);
}, [
location.pathname,
authLoading,
configLoading,
backendProbe.loading,
session,
]);
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
@@ -58,7 +66,13 @@ export default function Landing() {
void tick();
}, 5000);
return () => window.clearInterval(intervalId);
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]);
}, [
backendProbe.status,
backendProbe.loginDisabled,
backendProbe.probe,
navigate,
refetch,
]);
useEffect(() => {
if (backendProbe.status === "up") {
@@ -84,7 +98,14 @@ export default function Landing() {
// Show loading while checking auth and config
if (loading) {
return (
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">Loading...</div>
@@ -122,7 +143,11 @@ export default function Landing() {
border: "1px solid rgba(37, 99, 235, 0.2)",
}}
>
<p style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}>{t("backendStartup.unreachable")}</p>
<p
style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}
>
{t("backendStartup.unreachable")}
</p>
<button
type="button"
onClick={handleRetry}
+26 -8
View File
@@ -232,7 +232,9 @@ describe("Login", () => {
// Fill in form using getElementById
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
if (!emailInput || !passwordInput) {
throw new Error("Form inputs not found");
@@ -246,7 +248,9 @@ describe("Login", () => {
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
@@ -432,7 +436,9 @@ describe("Login", () => {
);
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "wrongpassword");
@@ -440,7 +446,9 @@ describe("Login", () => {
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
@@ -475,7 +483,9 @@ describe("Login", () => {
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
@@ -534,7 +544,11 @@ describe("Login", () => {
it("should redirect to home when login disabled", async () => {
mockBackendProbeState.loginDisabled = true;
mockProbe.mockResolvedValueOnce({ status: "up", loginDisabled: true, loading: false });
mockProbe.mockResolvedValueOnce({
status: "up",
loginDisabled: true,
loading: false,
});
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: false,
@@ -648,7 +662,9 @@ describe("Login", () => {
);
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password123");
@@ -656,7 +672,9 @@ describe("Login", () => {
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
+149 -35
View File
@@ -1,5 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
Navigate,
useLocation,
useNavigate,
useSearchParams,
} from "react-router-dom";
import { Text, Stack, Alert } from "@mantine/core";
import { springAuth } from "@app/auth/springAuthClient";
import { useAuth } from "@app/auth/UseSession";
@@ -17,7 +22,10 @@ import { updateSupportedLanguages } from "@app/i18n";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from "@app/routes/login/OAuthButtons";
import OAuthButtons, {
DEBUG_SHOW_ALL_PROVIDERS,
oauthProviderConfig,
} from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
@@ -44,7 +52,8 @@ export default function Login() {
const backendProbe = useBackendProbe();
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
const [showDefaultCredentials, setShowDefaultCredentials] = useState(false);
const loginDisabled = backendProbe.loginDisabled === true || _enableLogin === false;
const loginDisabled =
backendProbe.loginDisabled === true || _enableLogin === false;
const autoLoginAttempted = useRef(false);
const autoLoginErrorRecorded = useRef(false);
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
@@ -145,13 +154,24 @@ export default function Login() {
void tick();
}, 5000);
return () => window.clearInterval(intervalId);
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, refetch, navigate, loginDisabled]);
}, [
backendProbe.status,
backendProbe.loginDisabled,
backendProbe.probe,
refetch,
navigate,
loginDisabled,
]);
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
const returnPath = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname;
console.debug("[Login] User already authenticated, redirecting to home", { returnPath });
const returnPath = (
location.state as { from?: { pathname?: string } } | null
)?.from?.pathname;
console.debug("[Login] User already authenticated, redirecting to home", {
returnPath,
});
navigate(returnPath || "/", { replace: true });
}
}, [session, loading, navigate, location.state]);
@@ -175,7 +195,9 @@ export default function Login() {
useEffect(() => {
const fetchProviders = async () => {
try {
const response = await apiClient.get("/api/v1/proprietary/ui-data/login");
const response = await apiClient.get(
"/api/v1/proprietary/ui-data/login",
);
const data = response.data;
// Check if login is disabled - if so, redirect to home
@@ -222,7 +244,9 @@ export default function Login() {
// Update hasSSOProviders and showEmailForm when enabledProviders or loginMethod changes
useEffect(() => {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS ? Object.keys(oauthProviderConfig).length > 0 : enabledProviders.length > 0;
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
setHasSSOProviders(hasProviders);
// Check if username/password authentication is allowed
@@ -254,13 +278,17 @@ export default function Login() {
if (error) {
console.error(`[Login] ${provider} error:`, error);
setError(t("login.failedToSignIn", { provider, message: error.message }) || `Failed to sign in with ${provider}`);
setError(
t("login.failedToSignIn", { provider, message: error.message }) ||
`Failed to sign in with ${provider}`,
);
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err);
setError(
t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }) ||
"An unexpected error occurred",
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}) || "An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
@@ -279,11 +307,22 @@ export default function Login() {
const blockedByAttempts = attempts >= MAX_AUTO_LOGIN_ATTEMPTS;
const blockedByLogout = hasLogoutBlock();
if (!ssoAutoLogin || loginDisabled || loading || session || backendProbe.status !== "up") {
if (
!ssoAutoLogin ||
loginDisabled ||
loading ||
session ||
backendProbe.status !== "up"
) {
return;
}
if (hasSsoLoginError || blockedByErrors || blockedByAttempts || blockedByLogout) {
if (
hasSsoLoginError ||
blockedByErrors ||
blockedByAttempts ||
blockedByLogout
) {
return;
}
@@ -321,22 +360,40 @@ export default function Login() {
// Check if session expired (401 redirect)
const expired = searchParams.get("expired");
if (expired === "true") {
setError(t("login.sessionExpired", "Your session has expired. Please sign in again."));
setError(
t(
"login.sessionExpired",
"Your session has expired. Please sign in again.",
),
);
}
const messageType = searchParams.get("messageType");
if (messageType) {
switch (messageType) {
case "accountCreated":
setSuccessMessage(t("login.accountCreatedSuccess", "Account created successfully! You can now sign in."));
setSuccessMessage(
t(
"login.accountCreatedSuccess",
"Account created successfully! You can now sign in.",
),
);
break;
case "passwordChanged":
setSuccessMessage(
t("login.passwordChangedSuccess", "Password changed successfully! Please sign in with your new password."),
t(
"login.passwordChangedSuccess",
"Password changed successfully! Please sign in with your new password.",
),
);
break;
case "credsUpdated":
setSuccessMessage(t("login.credentialsUpdated", "Your credentials have been updated. Please sign in again."));
setSuccessMessage(
t(
"login.credentialsUpdated",
"Your credentials have been updated. Please sign in again.",
),
);
break;
}
}
@@ -361,9 +418,15 @@ export default function Login() {
// Set document meta
useDocumentMeta({
title: `${t("login.title", "Sign in")} - Stirling PDF`,
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("login.title", "Sign in")} - Stirling PDF`,
ogDescription: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
@@ -401,7 +464,11 @@ export default function Login() {
border: "1px solid rgba(37, 99, 235, 0.2)",
}}
>
<p style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}>{t("backendStartup.unreachable")}</p>
<p
style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}
>
{t("backendStartup.unreachable")}
</p>
<button
type="button"
onClick={handleRetry}
@@ -417,7 +484,9 @@ export default function Login() {
const signInWithEmail = async () => {
if (!email || !password) {
setError(t("login.pleaseEnterBoth") || "Please enter both email and password");
setError(
t("login.pleaseEnterBoth") || "Please enter both email and password",
);
return;
}
@@ -456,8 +525,9 @@ export default function Login() {
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }) ||
"An unexpected error occurred",
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}) || "An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
@@ -471,7 +541,10 @@ export default function Login() {
return (
<AuthLayout>
<LoginHeader title={isSingleSsoOnly ? "" : t("login.login") || "Sign in"} centerOnly={isSingleSsoOnly} />
<LoginHeader
title={isSingleSsoOnly ? "" : t("login.login") || "Sign in"}
centerOnly={isSingleSsoOnly}
/>
{/* Success message */}
{successMessage && (
@@ -485,7 +558,9 @@ export default function Login() {
color: "#16a34a",
}}
>
<p style={{ margin: 0, fontSize: "0.875rem", textAlign: "center" }}>{successMessage}</p>
<p style={{ margin: 0, fontSize: "0.875rem", textAlign: "center" }}>
{successMessage}
</p>
</div>
)}
@@ -497,14 +572,20 @@ export default function Login() {
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
ctaPrefix={isSsoOnlyMode ? t("login.signInWith", "Sign in with") : undefined}
ctaPrefix={
isSsoOnlyMode ? t("login.signInWith", "Sign in with") : undefined
}
styleVariant="light"
useNewStyle={isSsoOnlyMode}
/>
{/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
{hasSSOProviders && isUserPassAllowed && (
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
)}
{/* Sign in with email button - only show if SSO providers exist and username/password is allowed */}
@@ -535,7 +616,11 @@ export default function Login() {
requiresMfa={requiresMfa}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={isSigningIn ? t("login.loggingIn") || "Signing in..." : t("login.login") || "Sign in"}
submitButtonText={
isSigningIn
? t("login.loggingIn") || "Signing in..."
: t("login.login") || "Sign in"
}
/>
</div>
)}
@@ -544,23 +629,52 @@ export default function Login() {
{isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed && (
<Alert color="blue" variant="light" radius="md" mt="xl">
<Stack gap="xs" align="center">
<Text size="sm" fw={600} ta="center" style={{ color: "var(--text-always-dark)" }}>
<Text
size="sm"
fw={600}
ta="center"
style={{ color: "var(--text-always-dark)" }}
>
{t("login.defaultCredentials", "Default Login Credentials")}
</Text>
<Text size="sm" ta="center" style={{ color: "var(--text-always-dark)" }}>
<Text component="span" fw={600} style={{ color: "var(--text-always-dark)" }}>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-always-dark)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-always-dark)" }}
>
{t("login.username", "Username")}:
</Text>{" "}
admin
</Text>
<Text size="sm" ta="center" style={{ color: "var(--text-always-dark)" }}>
<Text component="span" fw={600} style={{ color: "var(--text-always-dark)" }}>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-always-dark)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-always-dark)" }}
>
{t("login.password", "Password")}:
</Text>{" "}
stirling
</Text>
<Text size="xs" ta="center" mt="xs" style={{ color: "var(--text-always-dark-muted)" }}>
{t("login.changePasswordWarning", "Please change your password after logging in for the first time")}
<Text
size="xs"
ta="center"
mt="xs"
style={{ color: "var(--text-always-dark-muted)" }}
>
{t(
"login.changePasswordWarning",
"Please change your password after logging in for the first time",
)}
</Text>
</Stack>
</Alert>
@@ -67,18 +67,29 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
shareMetadata = null;
}
const response = await apiClient.get(`/api/v1/storage/share-links/${normalizedToken}`, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
signal,
});
const response = await apiClient.get(
`/api/v1/storage/share-links/${normalizedToken}`,
{
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
signal,
},
);
if (signal.aborted) return;
const contentType = (response.headers && (response.headers["content-type"] || response.headers["Content-Type"])) || "";
const contentType =
(response.headers &&
(response.headers["content-type"] ||
response.headers["Content-Type"])) ||
"";
const disposition =
(response.headers && (response.headers["content-disposition"] || response.headers["Content-Disposition"])) || "";
const filename = parseContentDispositionFilename(disposition) || "shared-file";
(response.headers &&
(response.headers["content-disposition"] ||
response.headers["Content-Disposition"])) ||
"";
const filename =
parseContentDispositionFilename(disposition) || "shared-file";
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
@@ -110,9 +121,13 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
const entry = sortedEntries[i];
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId ? idMap.get(entry.parentLogicalId) : undefined;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) || idMap.get(manifest.rootLogicalId) || newId;
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
@@ -142,7 +157,10 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter((entry) => getShareBundleEntryRootId(manifest, entry) === rootId);
const rootEntries = sortedEntries.filter(
(entry) =>
getShareBundleEntryRootId(manifest, entry) === rootId,
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
@@ -162,7 +180,9 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
}
}
const file = new File([blob], filename, { type: contentTypeValue || blob.type });
const file = new File([blob], filename, {
type: contentTypeValue || blob.type,
});
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: true,
autoUnzip: false,
@@ -171,7 +191,9 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
if (signal.aborted) return;
if (stirlingFiles.length > 0) {
const ids = stirlingFiles.map((stirlingFile: StirlingFile) => stirlingFile.fileId);
const ids = stirlingFiles.map(
(stirlingFile: StirlingFile) => stirlingFile.fileId,
);
actions.setSelectedFiles(ids);
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
@@ -197,7 +219,10 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
if (!isAuthenticated && !authLoading) {
alert({
alertType: "warning",
title: t("storageShare.requiresLogin", "This shared file requires login."),
title: t(
"storageShare.requiresLogin",
"This shared file requires login.",
),
expandable: false,
durationMs: 4000,
});
@@ -241,7 +266,15 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
return () => {
abortController.abort();
};
}, [normalizedToken, actions, navActions, navigate, t, isAuthenticated, authLoading]);
}, [
normalizedToken,
actions,
navActions,
navigate,
t,
isAuthenticated,
authLoading,
]);
return null;
}
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Badge, Button, Group, Loader, Paper, Stack, Text, Title } from "@mantine/core";
import {
Alert,
Badge,
Button,
Group,
Loader,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import LoginIcon from "@mui/icons-material/Login";
@@ -17,7 +27,13 @@ import {
ShareLinkMetadata,
} from "@app/services/shareLinkImport";
type ShareLinkStatus = "loading" | "ready" | "login" | "forbidden" | "notfound" | "error";
type ShareLinkStatus =
| "loading"
| "ready"
| "login"
| "forbidden"
| "notfound"
| "error";
export default function ShareLinkPage() {
const { token } = useParams<{ token: string }>();
@@ -31,7 +47,10 @@ export default function ShareLinkPage() {
const normalizedToken = useMemo(() => (token || "").trim(), [token]);
const shareRole = (metadata?.accessRole ?? "viewer").toLowerCase();
const hasReadAccess = shareRole === "editor" || shareRole === "commenter" || shareRole === "viewer";
const hasReadAccess =
shareRole === "editor" ||
shareRole === "commenter" ||
shareRole === "viewer";
const canDownload = hasReadAccess;
const canOpen = hasReadAccess;
@@ -46,7 +65,9 @@ export default function ShareLinkPage() {
setMetadata(data);
setStatus("ready");
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
@@ -84,7 +105,9 @@ export default function ShareLinkPage() {
link.remove();
URL.revokeObjectURL(url);
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
@@ -94,7 +117,10 @@ export default function ShareLinkPage() {
} else {
alert({
alertType: "error",
title: t("storageShare.downloadFailed", "Unable to download this file."),
title: t(
"storageShare.downloadFailed",
"Unable to download this file.",
),
expandable: false,
durationMs: 3500,
});
@@ -108,14 +134,20 @@ export default function ShareLinkPage() {
if (!normalizedToken || !canOpen) return;
setIsWorking(true);
try {
const selectedIds = await importShareLinkToWorkbench(normalizedToken, actions, metadata);
const selectedIds = await importShareLinkToWorkbench(
normalizedToken,
actions,
metadata,
);
if (selectedIds.length > 0) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
@@ -135,15 +167,25 @@ export default function ShareLinkPage() {
}
}, [actions, canOpen, metadata, navActions, navigate, normalizedToken, t]);
const title = metadata?.fileName || t("storageShare.titleDefault", "Shared file");
const ownerLabel = metadata?.owner || t("storageShare.ownerUnknown", "Unknown");
const title =
metadata?.fileName || t("storageShare.titleDefault", "Shared file");
const ownerLabel =
metadata?.owner || t("storageShare.ownerUnknown", "Unknown");
return (
<div style={{ minHeight: "100%", padding: "2.5rem 1.5rem" }}>
<Paper radius="lg" p="xl" withBorder shadow="sm" style={{ maxWidth: 720, margin: "0 auto" }}>
<Paper
radius="lg"
p="xl"
withBorder
shadow="sm"
style={{ maxWidth: 720, margin: "0 auto" }}
>
<Stack gap="md">
<Group justify="space-between" align="center">
<Title order={3}>{t("storageShare.shareHeading", "Shared file")}</Title>
<Title order={3}>
{t("storageShare.shareHeading", "Shared file")}
</Title>
<Group gap="xs">
{metadata?.accessRole && (
<Badge variant="light" color="gray">
@@ -176,7 +218,8 @@ export default function ShareLinkPage() {
</Text>
{metadata?.createdAt && (
<Text size="sm" c="dimmed">
{t("storageShare.createdAt", "Created")} {new Date(metadata.createdAt).toLocaleString()}
{t("storageShare.createdAt", "Created")}{" "}
{new Date(metadata.createdAt).toLocaleString()}
</Text>
)}
<Group justify="flex-start" gap="sm" pt="sm">
@@ -199,7 +242,11 @@ export default function ShareLinkPage() {
</Button>
</Group>
{!canDownload && (
<Alert mt="md" color="yellow" title={t("storageShare.accessLimitedTitle", "Limited access")}>
<Alert
mt="md"
color="yellow"
title={t("storageShare.accessLimitedTitle", "Limited access")}
>
{shareRole === "commenter"
? t(
"storageShare.accessLimitedCommenter",
@@ -215,10 +262,21 @@ export default function ShareLinkPage() {
)}
{status === "login" && (
<Alert color="blue" title={t("storageShare.loginRequired", "Login required")}>
<Text size="sm">{t("storageShare.loginPrompt", "Sign in to access this shared file.")}</Text>
<Alert
color="blue"
title={t("storageShare.loginRequired", "Login required")}
>
<Text size="sm">
{t(
"storageShare.loginPrompt",
"Sign in to access this shared file.",
)}
</Text>
<Group mt="md">
<Button leftSection={<LoginIcon style={{ fontSize: 18 }} />} onClick={handleLogin}>
<Button
leftSection={<LoginIcon style={{ fontSize: 18 }} />}
onClick={handleLogin}
>
{t("storageShare.goToLogin", "Go to login")}
</Button>
</Group>
@@ -226,19 +284,37 @@ export default function ShareLinkPage() {
)}
{status === "forbidden" && (
<Alert color="red" title={t("storageShare.accessDeniedTitle", "No access")}>
{t("storageShare.accessDeniedBody", "You do not have access to this file. Ask the owner to share it with you.")}
<Alert
color="red"
title={t("storageShare.accessDeniedTitle", "No access")}
>
{t(
"storageShare.accessDeniedBody",
"You do not have access to this file. Ask the owner to share it with you.",
)}
</Alert>
)}
{status === "notfound" && (
<Alert color="red" title={t("storageShare.expiredTitle", "Link expired")}>
{t("storageShare.expiredBody", "This share link is invalid or has expired.")}
<Alert
color="red"
title={t("storageShare.expiredTitle", "Link expired")}
>
{t(
"storageShare.expiredBody",
"This share link is invalid or has expired.",
)}
</Alert>
)}
{status === "error" && (
<Alert color="red" title={t("storageShare.loadFailed", "Unable to open shared file.")}>
<Alert
color="red"
title={t(
"storageShare.loadFailed",
"Unable to open shared file.",
)}
>
{t("storageShare.tryAgain", "Please try again later.")}
</Alert>
)}
+31 -7
View File
@@ -12,7 +12,10 @@ import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import { useSignupFormValidation, SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
import {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
export default function Signup() {
@@ -39,9 +42,15 @@ export default function Signup() {
// Set document meta
useDocumentMeta({
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("signup.title", "Create an account")} - Stirling PDF`,
ogDescription: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
@@ -71,7 +80,11 @@ export default function Signup() {
}
} catch (err) {
console.error("[Signup] Unexpected error:", err);
setError(err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" }));
setError(
err instanceof Error
? err.message
: t("signup.unexpectedError", { message: "Unknown error" }),
);
} finally {
setIsSigningUp(false);
}
@@ -79,7 +92,10 @@ export default function Signup() {
return (
<AuthLayout>
<LoginHeader title={t("signup.title", "Create an account")} subtitle={t("signup.subtitle", "Join Stirling PDF")} />
<LoginHeader
title={t("signup.title", "Create an account")}
subtitle={t("signup.subtitle", "Join Stirling PDF")}
/>
<ErrorMessage error={error} />
@@ -98,11 +114,19 @@ export default function Signup() {
showTerms={false}
/>
<DividerWithText text={t("signup.or", "or")} respondsToDarkMode={false} opacity={0.4} />
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Bottom row - centered */}
<div style={{ textAlign: "center", margin: "0.5rem 0 0.25rem" }}>
<button type="button" onClick={() => navigate("/login")} className="auth-link-black">
<button
type="button"
onClick={() => navigate("/login")}
className="auth-link-black"
>
{t("login.logIn", "Log In")}
</button>
</div>
@@ -7,7 +7,8 @@
justify-content: center;
background-color: var(--auth-bg-color-light-only);
padding: 1.5rem 1.5rem 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: auto;
}
@@ -15,12 +15,17 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute("data-mantine-color-scheme");
const previousColorScheme = htmlElement.getAttribute(
"data-mantine-color-scheme",
);
// Set light mode
htmlElement.setAttribute("data-mantine-color-scheme", "light");
@@ -28,7 +33,10 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute("data-mantine-color-scheme", previousColorScheme);
htmlElement.setAttribute(
"data-mantine-color-scheme",
previousColorScheme,
);
}
};
}, []);
@@ -55,13 +63,31 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className={styles.authContainer}>
<div ref={cardRef} className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
>
<div className={styles.authLeftPanel}>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && <LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />}
{!hideRightPanel && (
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
)}
</div>
<div style={{ position: "fixed", bottom: 0, left: 0, right: 0, width: "100%", zIndex: 10 }}>
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
width: "100%",
zIndex: 10,
}}
>
<Footer forceLightMode={true} />
</div>
</div>
@@ -220,11 +220,19 @@
}
.oauth-button-vertical-tinted {
background: linear-gradient(90deg, color-mix(in srgb, var(--oauth-accent) 65%, #0f172a) 0 6px, #0f172a 6px 100%);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--oauth-accent) 65%, #0f172a) 0 6px,
#0f172a 6px 100%
);
}
.oauth-button-vertical-tinted:hover:not(:disabled) {
background: linear-gradient(90deg, color-mix(in srgb, var(--oauth-accent) 75%, #111827) 0 6px, #111827 6px 100%);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--oauth-accent) 75%, #111827) 0 6px,
#111827 6px 100%
);
}
.oauth-button-vertical-legacy {
@@ -378,8 +386,16 @@
}
.oauth-button-vertical-tinted .oauth-icon-wrapper {
background: color-mix(in srgb, var(--oauth-accent) 20%, rgba(255, 255, 255, 0.08));
border-color: color-mix(in srgb, var(--oauth-accent) 35%, rgba(255, 255, 255, 0.2));
background: color-mix(
in srgb,
var(--oauth-accent) 20%,
rgba(255, 255, 255, 0.08)
);
border-color: color-mix(
in srgb,
var(--oauth-accent) 35%,
rgba(255, 255, 255, 0.2)
);
}
.oauth-button-vertical-outline .oauth-icon-wrapper,
@@ -122,7 +122,12 @@ export default function EmailPasswordForm({
<Button
type="submit"
disabled={isSubmitting || !email || (showPasswordField && !password) || (requiresMfa && !mfaCode.trim())}
disabled={
isSubmitting ||
!email ||
(showPasswordField && !password) ||
(requiresMfa && !mfaCode.trim())
}
className="auth-button"
fullWidth
loading={isSubmitting}
@@ -40,10 +40,27 @@ export default function LoggedInState() {
}}
>
<div style={{ textAlign: "center", marginBottom: "24px" }}>
<div style={{ marginBottom: "16px", display: "flex", justifyContent: "center" }}>
<img src={logoPath} alt="Stirling PDF Logo" style={{ width: "64px", height: "64px", objectFit: "contain" }} />
<div
style={{
marginBottom: "16px",
display: "flex",
justifyContent: "center",
}}
>
<img
src={logoPath}
alt="Stirling PDF Logo"
style={{ width: "64px", height: "64px", objectFit: "contain" }}
/>
</div>
<h1 style={{ fontSize: "24px", fontWeight: "bold", color: "#059669", marginBottom: "8px" }}>
<h1
style={{
fontSize: "24px",
fontWeight: "bold",
color: "#059669",
marginBottom: "8px",
}}
>
{t("login.youAreLoggedIn")}
</h1>
<p style={{ color: "#6b7280", fontSize: "14px" }}>
@@ -52,7 +69,9 @@ export default function LoggedInState() {
</div>
<div style={{ textAlign: "center", marginTop: "16px" }}>
<p style={{ color: "#6b7280", fontSize: "14px" }}>Redirecting to home...</p>
<p style={{ color: "#6b7280", fontSize: "14px" }}>
Redirecting to home...
</p>
</div>
</div>
</div>
@@ -6,13 +6,23 @@ interface LoginHeaderProps {
centerOnly?: boolean;
}
export default function LoginHeader({ title, subtitle, centerOnly = false }: LoginHeaderProps) {
export default function LoginHeader({
title,
subtitle,
centerOnly = false,
}: LoginHeaderProps) {
const { wordmark } = useLogoAssets();
return (
<div className={`login-header${centerOnly ? " login-header-centered" : ""}`}>
<div
className={`login-header${centerOnly ? " login-header-centered" : ""}`}
>
<div className="login-header-logos">
<img src={wordmark.black} alt="Stirling PDF" className="login-logo-text" />
<img
src={wordmark.black}
alt="Stirling PDF"
className="login-logo-text"
/>
</div>
{title && <h1 className="login-title">{title}</h1>}
{subtitle && <p className="login-subtitle">{subtitle}</p>}
@@ -6,10 +6,19 @@ interface NavigationLinkProps {
isDisabled?: boolean;
}
export default function NavigationLink({ onClick, text, isDisabled = false }: NavigationLinkProps) {
export default function NavigationLink({
onClick,
text,
isDisabled = false,
}: NavigationLinkProps) {
return (
<div className="navigation-link-container">
<Button onClick={onClick} disabled={isDisabled} className="navigation-link-button" variant="subtle">
<Button
onClick={onClick}
disabled={isDisabled}
className="navigation-link-button"
variant="subtle"
>
{text}
</Button>
</div>
@@ -11,7 +11,9 @@ vi.mock("react-i18next", () => ({
}),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => <MantineProvider>{children}</MantineProvider>;
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe("OAuthButtons", () => {
const mockOnProviderClick = vi.fn();
@@ -25,7 +27,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -40,7 +46,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -60,7 +70,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -77,7 +91,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -93,7 +111,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -108,12 +130,20 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={true} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={true}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
const googleButton = screen.getByText("Google").closest("button") as HTMLButtonElement;
const githubButton = screen.getByText("GitHub").closest("button") as HTMLButtonElement;
const googleButton = screen
.getByText("Google")
.closest("button") as HTMLButtonElement;
const githubButton = screen
.getByText("GitHub")
.closest("button") as HTMLButtonElement;
expect(googleButton.disabled).toBe(true);
expect(githubButton.disabled).toBe(true);
@@ -122,12 +152,18 @@ describe("OAuthButtons", () => {
it("should render nothing when no providers are enabled", () => {
const { container } = render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={[]} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={[]}
/>
</TestWrapper>,
);
// Should render null/nothing (excluding Mantine's style tags)
const hasContent = Array.from(container.children).some((child) => child.tagName.toLowerCase() !== "style");
const hasContent = Array.from(container.children).some(
(child) => child.tagName.toLowerCase() !== "style",
);
expect(hasContent).toBe(false);
});
@@ -137,7 +173,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -162,7 +202,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -174,7 +218,9 @@ describe("OAuthButtons", () => {
expect(githubButton?.querySelector("img")?.src).toContain("github.svg");
const authentikButton = screen.getByText("Authentik").closest("button");
expect(authentikButton?.querySelector("img")?.src).toContain("authentik.svg");
expect(authentikButton?.querySelector("img")?.src).toContain(
"authentik.svg",
);
const keycloakButton = screen.getByText("Keycloak").closest("button");
expect(keycloakButton?.querySelector("img")?.src).toContain("keycloak.svg");
@@ -186,7 +232,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -223,7 +273,11 @@ describe("OAuthButtons", () => {
render(
<TestWrapper>
<OAuthButtons onProviderClick={mockOnProviderClick} isSubmitting={false} enabledProviders={enabledProviders} />
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
@@ -9,7 +9,10 @@ export const DEBUG_SHOW_ALL_PROVIDERS = false;
// OAuth provider configuration - maps provider ID to display info
// Known providers get custom icons; unknown providers use generic SSO icon
export const oauthProviderConfig: Record<string, { label: string; file: string }> = {
export const oauthProviderConfig: Record<
string,
{ label: string; file: string }
> = {
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
apple: { label: "Apple", file: "apple.svg" },
@@ -47,7 +50,9 @@ export default function OAuthButtons({
const { t } = useTranslation();
// Debug mode: show all providers for UI testing
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS ? Object.keys(oauthProviderConfig) : enabledProviders;
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig)
: enabledProviders;
// Build provider list - extract provider ID from full path for display
const providers = providersToShow.map((pathOrId) => {
@@ -95,7 +100,10 @@ export default function OAuthButtons({
return (
<div className="oauth-container-icons">
{providers.map((p) => (
<div key={p.id} title={`${t("login.signInWith", "Sign in with")} ${p.label}`}>
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
@@ -103,7 +111,11 @@ export default function OAuthButtons({
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
>
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-small" />
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-small"
/>
</Button>
</div>
))}
@@ -115,7 +127,10 @@ export default function OAuthButtons({
return (
<div className="oauth-container-grid">
{providers.map((p) => (
<div key={p.id} title={`${t("login.signInWith", "Sign in with")} ${p.label}`}>
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
@@ -123,7 +138,11 @@ export default function OAuthButtons({
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
>
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-medium" />
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-medium"
/>
</Button>
</div>
))}
@@ -132,26 +151,47 @@ export default function OAuthButtons({
}
return (
<div className={`oauth-container-vertical${useNewStyle && isSingleProvider ? " oauth-container-single" : ""}`}>
<div
className={`oauth-container-vertical${useNewStyle && isSingleProvider ? " oauth-container-single" : ""}`}
>
{providers.map((p) => (
<div key={p.id} title={`${t("login.signInWith", "Sign in with")} ${p.label}`}>
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={!demoMode && isSubmitting}
className={`oauth-button-vertical${useNewStyle && isSingleProvider ? " oauth-button-vertical-single" : ""}${!useNewStyle ? " oauth-button-vertical-legacy" : ""}${isTinted ? " oauth-button-vertical-tinted" : ""}${isOutline ? " oauth-button-vertical-outline" : ""}${isLight ? " oauth-button-vertical-light" : ""}`}
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
style={isTinted ? ({ "--oauth-accent": accentMap[p.providerId] || "#334155" } as React.CSSProperties) : undefined}
style={
isTinted
? ({
"--oauth-accent": accentMap[p.providerId] || "#334155",
} as React.CSSProperties)
: undefined
}
>
<span className="oauth-button-left">
<span className="oauth-icon-wrapper">
<img src={`${BASE_PATH}/Login/${p.file}`} alt={p.label} className="oauth-icon-tiny" />
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-tiny"
/>
</span>
<span className="oauth-button-text">
{ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label}
</span>
<span className="oauth-button-text">{ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label}</span>
</span>
{useNewStyle && isSingleProvider && (
<span className="oauth-button-right" aria-hidden="true">
<svg className="oauth-arrow-icon" viewBox="0 0 24 24" fill="none">
<svg
className="oauth-arrow-icon"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5 12h12m0 0-5-5m5 5-5 5"
stroke="currentColor"

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