mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
+246
-57
@@ -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>
|
||||
|
||||
+707
-248
File diff suppressed because it is too large
Load Diff
+75
-16
@@ -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>
|
||||
|
||||
+306
-90
@@ -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
|
||||
|
||||
+439
-130
@@ -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>
|
||||
|
||||
+93
-21
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+163
-58
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+494
-175
File diff suppressed because it is too large
Load Diff
+102
-23
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+125
-39
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+32
-8
@@ -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,
|
||||
|
||||
+72
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+128
-59
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+679
-200
File diff suppressed because it is too large
Load Diff
+154
-57
@@ -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
|
||||
|
||||
+170
-38
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+300
-71
@@ -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>
|
||||
|
||||
+284
-71
@@ -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>
|
||||
|
||||
+179
-46
@@ -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>
|
||||
|
||||
+20
-5
@@ -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
-2
@@ -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}>
|
||||
|
||||
+43
-10
@@ -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;
|
||||
|
||||
+99
-26
@@ -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)",
|
||||
|
||||
+72
-15
@@ -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>
|
||||
|
||||
+167
-36
@@ -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>
|
||||
|
||||
+97
-27
@@ -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}
|
||||
|
||||
+66
-14
@@ -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>
|
||||
|
||||
+70
-15
@@ -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
|
||||
|
||||
+45
-8
@@ -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>
|
||||
|
||||
+33
-7
@@ -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>
|
||||
);
|
||||
|
||||
+40
-20
@@ -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">
|
||||
✓
|
||||
|
||||
+132
-32
@@ -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>
|
||||
|
||||
+62
-21
@@ -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
|
||||
|
||||
+112
-22
@@ -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>
|
||||
)}
|
||||
|
||||
+106
-21
@@ -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")}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+45
-5
@@ -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>
|
||||
)}
|
||||
|
||||
+14
-2
@@ -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}>
|
||||
{" "}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+33
-6
@@ -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
|
||||
|
||||
+13
-4
@@ -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({
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+77
-22
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user