Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
+1 -3
View File
@@ -28,9 +28,7 @@ import "@app/utils/fileIdSafety";
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
+60 -65
View File
@@ -1,7 +1,7 @@
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 { createContext, useContext, useEffect, useState, ReactNode, useCallback } from "react";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import type { Session, User, AuthError, AuthChangeEvent } from "@app/auth/springAuthClient";
/**
* Auth Context Type
@@ -38,12 +38,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Debug: Track state transitions
useEffect(() => {
console.log('[Auth] State changed:', {
console.log("[Auth] State changed:", {
loading,
hasSession: !!session,
hasError: !!error,
userId: session?.user?.id,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
});
}, [loading, session, error]);
@@ -54,24 +54,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
setLoading(true);
setError(null);
console.debug('[Auth] refreshSession: start', { path: window.location.pathname });
console.debug('[Auth] Refreshing session...');
console.debug("[Auth] refreshSession: start", { path: window.location.pathname });
console.debug("[Auth] Refreshing session...");
const { data, error } = await springAuth.refreshSession();
if (error) {
console.error('[Auth] Session refresh error:', error);
console.error("[Auth] Session refresh error:", error);
setError(error);
setSession(null);
} else {
console.debug('[Auth] Session refreshed successfully');
console.debug("[Auth] Session refreshed successfully");
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during session refresh:', err);
console.error("[Auth] Unexpected error during session refresh:", err);
setError(err as AuthError);
} finally {
console.debug('[Auth] refreshSession: done', { hasSession: !!session });
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
setLoading(false);
}
}, []);
@@ -82,20 +82,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = useCallback(async () => {
try {
setError(null);
console.debug('[Auth] Signing out...');
console.debug("[Auth] Signing out...");
const { error } = await springAuth.signOut();
if (error) {
console.error('[Auth] Sign out error:', error);
console.error("[Auth] Sign out error:", error);
setError(error);
} else {
console.debug('[Auth] Signed out successfully');
console.debug("[Auth] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error('[Auth] Unexpected error during sign out:', err);
console.error("[Auth] Unexpected error during sign out:", err);
setError(err as AuthError);
}
}, []);
@@ -113,7 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.debug(`[Auth:${mountId}] Initializing auth...`);
console.debug(`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`);
// Clear any platform-specific cached auth on login page init.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
if (typeof window !== "undefined" && window.location.pathname.startsWith("/login")) {
await clearPlatformAuthOnLoginInit();
}
@@ -124,10 +123,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!mounted) return;
if (error) {
console.error('[Auth] Initial session error:', error);
console.error("[Auth] Initial session error:", error);
setError(error);
} else {
console.debug('[Auth] Initial session loaded:', {
console.debug("[Auth] Initial session loaded:", {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
@@ -135,7 +134,7 @@ 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);
}
@@ -161,50 +160,50 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void initializeAuth();
};
window.addEventListener('jwt-available', handleJwtAvailable);
window.addEventListener("jwt-available", handleJwtAvailable);
// Subscribe to auth state changes
const { data: { subscription } } = springAuth.onAuthStateChange(
async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`);
return;
}
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
console.log(`[Auth:${mountId}] User: ${newSession?.user?.email || 'none'}`);
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
// Schedule state update
setTimeout(() => {
if (mounted) {
console.log(`[Auth:${mountId}] Applying session update (event: ${event})`);
setSession(newSession);
setError(null);
// Handle specific events
if (event === 'SIGNED_OUT') {
console.log(`[Auth:${mountId}] ✓ User signed out, session cleared`);
} else if (event === 'SIGNED_IN') {
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
} else if (event === 'TOKEN_REFRESHED') {
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
} else if (event === 'USER_UPDATED') {
console.log(`[Auth:${mountId}] ✓ User updated`);
}
} else {
console.log(`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`);
}
}, 0);
const {
data: { subscription },
} = springAuth.onAuthStateChange(async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) {
console.log(`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`);
return;
}
);
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
console.log(`[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`);
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
// Schedule state update
setTimeout(() => {
if (mounted) {
console.log(`[Auth:${mountId}] Applying session update (event: ${event})`);
setSession(newSession);
setError(null);
// Handle specific events
if (event === "SIGNED_OUT") {
console.log(`[Auth:${mountId}] ✓ User signed out, session cleared`);
} else if (event === "SIGNED_IN") {
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
} else if (event === "TOKEN_REFRESHED") {
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
} else if (event === "USER_UPDATED") {
console.log(`[Auth:${mountId}] ✓ User updated`);
}
} else {
console.log(`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`);
}
}, 0);
});
return () => {
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
mounted = false;
window.removeEventListener('jwt-available', handleJwtAvailable);
window.removeEventListener("jwt-available", handleJwtAvailable);
subscription.unsubscribe();
};
}, []);
@@ -218,11 +217,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshSession,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
@@ -233,7 +228,7 @@ export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
@@ -247,7 +242,7 @@ export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
console.debug("[Auth Debug] Current auth state:", {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
@@ -2,7 +2,7 @@
* Helper utilities for clearing cached OAuth redirect/session state
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
/**
* Clear any persisted OAuth redirect path/cached state so the app
@@ -11,21 +11,21 @@ const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
export function resetOAuthState(): void {
try {
// Remove redirect cookie
if (typeof document !== 'undefined') {
if (typeof document !== "undefined") {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear redirect cookie', err);
console.warn("[OAuthStorage] Failed to clear redirect cookie", err);
}
// Remove any related localStorage entries we might have used
try {
if (typeof window !== 'undefined' && window.localStorage) {
if (typeof window !== "undefined" && window.localStorage) {
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
window.localStorage.removeItem('oauth_redirect_path');
window.localStorage.removeItem("oauth_redirect_path");
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear OAuth localStorage', err);
console.warn("[OAuthStorage] Failed to clear OAuth localStorage", err);
}
}
+9 -9
View File
@@ -3,17 +3,17 @@
* Custom providers are also supported - the backend determines availability.
*/
export const KNOWN_OAUTH_PROVIDERS = [
'github',
'google',
'apple',
'azure',
'keycloak',
'cloudron',
'authentik',
'oidc',
"github",
"google",
"apple",
"azure",
"keycloak",
"cloudron",
"authentik",
"oidc",
] as const;
export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number];
export type KnownOAuthProvider = (typeof KNOWN_OAUTH_PROVIDERS)[number];
/**
* OAuth provider ID - can be any known provider or custom string.
@@ -1,16 +1,16 @@
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 { 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";
// Mock apiClient
vi.mock('@app/services/apiClient');
vi.mock('@app/extensions/oauthNavigation', () => ({
vi.mock("@app/services/apiClient");
vi.mock("@app/extensions/oauthNavigation", () => ({
startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));
describe('SpringAuthClient', () => {
describe("SpringAuthClient", () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
@@ -22,8 +22,8 @@ describe('SpringAuthClient', () => {
vi.restoreAllMocks();
});
describe('getSession', () => {
it('should return null session when no JWT in localStorage', async () => {
describe("getSession", () => {
it("should return null session when no JWT in localStorage", async () => {
const result = await springAuth.getSession();
expect(result.data.session).toBeNull();
@@ -31,16 +31,16 @@ describe('SpringAuthClient', () => {
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should validate JWT and return session when JWT exists', async () => {
const mockToken = 'mock-jwt-token';
it("should validate JWT and return session when JWT exists", async () => {
const mockToken = "mock-jwt-token";
const mockUser = {
id: '123',
email: '[email protected]',
username: 'testuser',
role: 'USER',
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
localStorage.setItem('stirling_jwt', mockToken);
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
@@ -49,7 +49,7 @@ describe('SpringAuthClient', () => {
const result = await springAuth.getSession();
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
skipAuthRedirect: true,
@@ -60,76 +60,64 @@ describe('SpringAuthClient', () => {
expect(result.error).toBeNull();
});
it('should clear invalid JWT on 401 error', async () => {
const mockToken = 'invalid-jwt-token';
localStorage.setItem('stirling_jwt', mockToken);
it("should clear invalid JWT on 401 error", async () => {
const mockToken = "invalid-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
const mockError = new AxiosError(
'Unauthorized',
'ERR_BAD_REQUEST',
undefined,
undefined,
{
status: 401,
statusText: 'Unauthorized',
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
}
);
const mockError = new AxiosError("Unauthorized", "ERR_BAD_REQUEST", undefined, undefined, {
status: 401,
statusText: "Unauthorized",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 401 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
it('should clear invalid JWT on 403 error', async () => {
const mockToken = 'forbidden-jwt-token';
localStorage.setItem('stirling_jwt', mockToken);
it("should clear invalid JWT on 403 error", async () => {
const mockToken = "forbidden-jwt-token";
localStorage.setItem("stirling_jwt", mockToken);
const mockError = new AxiosError(
'Forbidden',
'ERR_BAD_REQUEST',
undefined,
undefined,
{
status: 403,
statusText: 'Forbidden',
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
}
);
const mockError = new AxiosError("Forbidden", "ERR_BAD_REQUEST", undefined, undefined, {
status: 403,
statusText: "Forbidden",
data: {},
headers: {},
config: {} as InternalAxiosRequestConfig,
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
// 403 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
});
describe('signInWithPassword', () => {
it('should successfully sign in with email and password', async () => {
describe("signInWithPassword", () => {
it("should successfully sign in with email and password", async () => {
const credentials = {
email: '[email protected]',
password: 'password123',
email: "[email protected]",
password: "password123",
};
const mockToken = 'new-jwt-token';
const mockToken = "new-jwt-token";
const mockUser = {
id: '123',
id: "123",
email: credentials.email,
username: credentials.email,
role: 'USER',
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -144,34 +132,32 @@ describe('SpringAuthClient', () => {
} as unknown as AxiosResponse);
// Spy on window.dispatchEvent
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
const result = await springAuth.signInWithPassword(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/login',
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true }
);
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
{ withCredentials: true },
);
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "jwt-available" }));
expect(result.user).toEqual(mockUser);
expect(result.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it('should return error on failed login', async () => {
it("should return error on failed login", async () => {
const credentials = {
email: '[email protected]',
password: 'wrongpassword',
email: "[email protected]",
password: "wrongpassword",
};
const errorMessage = 'Invalid credentials';
const errorMessage = "Invalid credentials";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
@@ -191,18 +177,18 @@ describe('SpringAuthClient', () => {
});
});
describe('signUp', () => {
it('should successfully register new user', async () => {
describe("signUp", () => {
it("should successfully register new user", async () => {
const credentials = {
email: '[email protected]',
password: 'newpassword123',
email: "[email protected]",
password: "newpassword123",
};
const mockUser = {
id: '456',
id: "456",
email: credentials.email,
username: credentials.email,
role: 'USER',
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -213,25 +199,25 @@ describe('SpringAuthClient', () => {
const result = await springAuth.signUp(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/user/register',
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true }
{ withCredentials: true },
);
expect(result.user).toEqual(mockUser);
expect(result.session).toBeNull(); // No auto-login on signup
expect(result.error).toBeNull();
});
it('should return error on failed registration', async () => {
it("should return error on failed registration", async () => {
const credentials = {
email: '[email protected]',
password: 'password123',
email: "[email protected]",
password: "password123",
};
const errorMessage = 'User already exists';
const errorMessage = "User already exists";
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
@@ -251,10 +237,10 @@ describe('SpringAuthClient', () => {
});
});
describe('signOut', () => {
it('should successfully sign out and clear JWT', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
describe("signOut", () => {
it("should successfully sign out and clear JWT", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
@@ -264,39 +250,39 @@ describe('SpringAuthClient', () => {
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/logout',
"/api/v1/auth/logout",
null,
expect.objectContaining({ withCredentials: true })
expect.objectContaining({ withCredentials: true }),
);
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.error).toBeNull();
});
it('should clear JWT even if logout request fails', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
it("should clear JWT even if logout request fails", async () => {
const mockToken = "jwt-to-clear";
localStorage.setItem("stirling_jwt", mockToken);
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: 'Server error',
message: "Server error",
});
const result = await springAuth.signOut();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('refreshSession', () => {
it('should refresh JWT token successfully', async () => {
const newToken = 'refreshed-jwt-token';
describe("refreshSession", () => {
it("should refresh JWT token successfully", async () => {
const newToken = "refreshed-jwt-token";
const mockUser = {
id: '123',
email: '[email protected]',
username: 'testuser',
role: 'USER',
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
@@ -312,33 +298,33 @@ describe('SpringAuthClient', () => {
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
it('should clear JWT and return error on 401', async () => {
localStorage.setItem('stirling_jwt', 'expired-token');
it("should clear JWT and return error on 401", async () => {
localStorage.setItem("stirling_jwt", "expired-token");
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: 'Token expired',
message: "Token expired",
});
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
expect(result.data.session).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('signInWithOAuth', () => {
it('should redirect to OAuth provider', async () => {
describe("signInWithOAuth", () => {
it("should redirect to OAuth provider", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
@@ -346,18 +332,18 @@ describe('SpringAuthClient', () => {
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(result.error).toBeNull();
});
it('should skip redirect when handled by extension', async () => {
it("should skip redirect when handled by extension", async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
Object.defineProperty(window, "location", {
value: { assign: mockAssign },
writable: true,
});
@@ -365,11 +351,11 @@ describe('SpringAuthClient', () => {
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
provider: "/oauth2/authorization/github",
options: { redirectTo: "/auth/callback" },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(startOAuthNavigation).toHaveBeenCalledWith("/oauth2/authorization/github");
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
+123 -125
View File
@@ -7,28 +7,28 @@
* - No email confirmation flow (auto-confirmed on registration)
*/
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { resetOAuthState } from '@app/auth/oauthStorage';
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
import apiClient from "@app/services/apiClient";
import { AxiosError } from "axios";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { resetOAuthState } from "@app/auth/oauthStorage";
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
import {
getPlatformSessionUser,
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
} from '@app/extensions/platformSessionBridge';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === 'object' && 'response' in error) {
if (error && typeof error === "object" && "response" in error) {
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === 'number') {
if (response && typeof response.status === "number") {
return response.status;
}
}
@@ -44,26 +44,26 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ''}/auth/callback`;
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== 'string') {
if (!target || typeof target !== "string") {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || '/';
const query = parsed.search || '';
const path = parsed.pathname || "/";
const query = parsed.search || "";
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
}
@@ -108,11 +108,7 @@ 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;
@@ -142,15 +138,15 @@ 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;
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0;
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
if (expSeconds <= 0 || iatSeconds <= 0) {
console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals');
console.warn("[SpringAuth] Token missing exp/iat claims, using default intervals");
return;
}
@@ -166,30 +162,30 @@ class SpringAuthClient {
// Max: 5 minutes (don't wait too long for long-lived tokens)
this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
console.log('[SpringAuth] 📊 Adaptive intervals calculated:', {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's',
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's',
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's',
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + "s",
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + "s",
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error);
console.warn("[SpringAuth] Failed to calculate adaptive intervals:", error);
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.');
const parts = token.split(".");
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
return JSON.parse(atob(base64));
}
@@ -198,10 +194,10 @@ class SpringAuthClient {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error('Token payload missing');
throw new Error("Token payload missing");
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
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));
@@ -217,10 +213,10 @@ class SpringAuthClient {
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(';');
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
const [name, value] = cookie.trim().split("=");
if (name === "XSRF-TOKEN") {
return decodeURIComponent(value);
}
}
@@ -234,7 +230,7 @@ class SpringAuthClient {
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
let token = localStorage.getItem('stirling_jwt');
let token = localStorage.getItem("stirling_jwt");
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
@@ -246,13 +242,13 @@ class SpringAuthClient {
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem('stirling_jwt');
const refreshedToken = localStorage.getItem("stirling_jwt");
if (!refreshedToken) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
@@ -261,7 +257,7 @@ class SpringAuthClient {
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
@@ -269,10 +265,10 @@ class SpringAuthClient {
const session: Session = {
user: {
id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
email: platformUser?.email || '',
username: platformUser?.username || platformUser?.email || 'User',
role: 'USER',
id: platformUser?.email || platformUser?.username || "desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
role: "USER",
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
@@ -285,9 +281,9 @@ class SpringAuthClient {
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await apiClient.get('/api/v1/auth/me', {
const response = await apiClient.get("/api/v1/auth/me", {
headers: {
'Authorization': `Bearer ${token}`,
Authorization: `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
@@ -310,7 +306,7 @@ class SpringAuthClient {
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] getSession error:', error);
console.error("[SpringAuth] getSession error:", error);
// If 401/403, token is invalid - try explicit refresh
const status = getHttpStatus(error);
@@ -321,8 +317,8 @@ class SpringAuthClient {
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated');
localStorage.removeItem("stirling_jwt");
console.debug("[SpringAuth] getSession: Not authenticated");
return { data: { session: null }, error: null };
}
@@ -330,7 +326,7 @@ class SpringAuthClient {
// The token is still valid, just can't verify it right now
return {
data: { session: null },
error: { message: getErrorMessage(error, 'Unknown error') },
error: { message: getErrorMessage(error, "Unknown error") },
};
}
}
@@ -338,25 +334,25 @@ 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', {
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
}, {
withCredentials: true, // Include cookies for CSRF
});
const response = await apiClient.post(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
},
{
withCredentials: true, // Include cookies for CSRF
},
);
const data = response.data;
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
localStorage.setItem("stirling_jwt", token);
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
@@ -366,7 +362,7 @@ class SpringAuthClient {
this.calculateAdaptiveIntervals(token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
window.dispatchEvent(new CustomEvent("jwt-available"));
const session: Session = {
user: data.user,
@@ -376,18 +372,14 @@ class SpringAuthClient {
};
// Notify listeners
this.notifyListeners('SIGNED_IN', session);
this.notifyListeners("SIGNED_IN", session);
return { user: data.user, session, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signInWithPassword error:', error);
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,
@@ -395,14 +387,14 @@ class SpringAuthClient {
message: errorMessage,
status: error.response?.status,
code: errorCode,
mfaRequired: errorCode === 'mfa_required',
mfaRequired: errorCode === "mfa_required",
},
};
}
return {
user: null,
session: null,
error: { message: getErrorMessage(error, 'Login failed') },
error: { message: getErrorMessage(error, "Login failed") },
};
}
}
@@ -416,12 +408,16 @@ class SpringAuthClient {
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/user/register', {
username: credentials.email,
password: credentials.password,
}, {
withCredentials: true,
});
const response = await apiClient.post(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{
withCredentials: true,
},
);
const data = response.data;
@@ -429,11 +425,11 @@ class SpringAuthClient {
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signUp error:', error);
console.error("[SpringAuth] signUp error:", error);
return {
user: null,
session: null,
error: { message: getErrorMessage(error, 'Registration failed') },
error: { message: getErrorMessage(error, "Registration failed") },
};
}
}
@@ -466,7 +462,7 @@ 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" },
};
}
}
@@ -476,12 +472,12 @@ class SpringAuthClient {
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1');
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
}
const response = await apiClient.post('/api/v1/auth/logout', null, {
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
@@ -491,52 +487,52 @@ class SpringAuthClient {
}
// Clean up local storage
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
try {
Object.keys(localStorage)
.filter((key) => key.startsWith('sb-') || key.includes('supabase'))
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err);
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('=');
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
if (name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
}
});
} catch (err) {
console.warn('[SpringAuth] Failed to clear cookies on sign out', err);
console.warn("[SpringAuth] Failed to clear cookies on sign out", err);
}
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
console.warn("[SpringAuth] Failed to run platform auth cleanup", cleanupError);
}
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
this.notifyListeners("SIGNED_OUT", null);
return { error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signOut error:', error);
console.error("[SpringAuth] signOut error:", error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
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') },
error: { message: getErrorMessage(error, "Logout failed") },
};
}
}
@@ -549,10 +545,10 @@ class SpringAuthClient {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem("stirling_jwt");
return {
data: { session: null },
error: { message: 'Token refresh failed - please log in again' },
error: { message: "Token refresh failed - please log in again" },
};
}
@@ -560,23 +556,23 @@ 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" },
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem('stirling_jwt');
const token = localStorage.getItem("stirling_jwt");
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners('TOKEN_REFRESHED', data.session);
this.notifyListeners("TOKEN_REFRESHED", data.session);
return { data, error: null };
}
const response = await apiClient.post('/api/v1/auth/refresh', null, {
const response = await apiClient.post("/api/v1/auth/refresh", null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
@@ -586,7 +582,7 @@ class SpringAuthClient {
const token = data.session.access_token;
// Update local storage with new token
localStorage.setItem('stirling_jwt', token);
localStorage.setItem("stirling_jwt", token);
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
@@ -602,24 +598,24 @@ class SpringAuthClient {
};
// Notify listeners
this.notifyListeners('TOKEN_REFRESHED', session);
this.notifyListeners("TOKEN_REFRESHED", session);
console.debug('[SpringAuth] Token refreshed successfully');
console.debug("[SpringAuth] Token refreshed successfully");
return { data: { session }, error: null };
} catch (error: unknown) {
console.error('[SpringAuth] refreshSession error:', error);
localStorage.removeItem('stirling_jwt');
console.error("[SpringAuth] refreshSession error:", error);
localStorage.removeItem("stirling_jwt");
// Handle different error statuses
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
return { data: { session: null }, error: { message: "Token refresh failed - please log in again" } };
}
return {
data: { session: null },
error: { message: getErrorMessage(error, 'Token refresh failed') },
error: { message: getErrorMessage(error, "Token refresh failed") },
};
}
}
@@ -650,7 +646,7 @@ 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);
@@ -670,12 +666,14 @@ class SpringAuthClient {
// Refresh if token expires soon (threshold is adaptive)
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)');
console.log(
"[SpringAuth] 🔄 Proactively refreshing token (expires in " + Math.floor(timeUntilExpiry / 1000) + "s)",
);
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
console.error("[SpringAuth] Session monitoring error:", error);
}
}, this.sessionCheckIntervalMs);
}
@@ -720,14 +718,14 @@ export const isUserAnonymous = (user: User | null) => {
*/
export const createAnonymousUser = (): User => {
return {
id: 'anonymous',
email: 'anonymous@local',
username: 'Anonymous User',
role: 'USER',
id: "anonymous",
email: "anonymous@local",
username: "Anonymous User",
role: "USER",
enabled: true,
is_anonymous: true,
app_metadata: {
provider: 'anonymous',
provider: "anonymous",
},
};
};
@@ -738,7 +736,7 @@ export const createAnonymousUser = (): User => {
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: '',
access_token: "",
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
@@ -8,10 +8,7 @@ import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
<CoreAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<CoreAppProviders appConfigRetryOptions={appConfigRetryOptions} appConfigProviderProps={appConfigProviderProps}>
<AuthProvider>
<LicenseProvider>
<UpdateSeatsProvider>
@@ -1,22 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Button,
Checkbox,
CloseButton,
Group,
Modal,
PasswordInput,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { ChangeUserPasswordRequest, User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, CloseButton, Group, Modal, PasswordInput, Stack, Text, Tooltip } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { ChangeUserPasswordRequest, User, userManagementService } from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface ChangeUserPasswordModalProps {
opened: boolean;
@@ -27,9 +16,9 @@ interface ChangeUserPasswordModalProps {
}
function generateSecurePassword() {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&';
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
const length = 14;
let password = '';
let password = "";
const charsetLength = charset.length;
const uint8Array = new Uint8Array(length);
window.crypto.getRandomValues(uint8Array);
@@ -52,11 +41,17 @@ function generateSecurePassword() {
return password;
}
export default function ChangeUserPasswordModal({ opened, onClose, user, onSuccess, mailEnabled }: ChangeUserPasswordModalProps) {
export default function ChangeUserPasswordModal({
opened,
onClose,
user,
onSuccess,
mailEnabled,
}: ChangeUserPasswordModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState({
newPassword: '',
confirmPassword: '',
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
@@ -75,16 +70,19 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
if (!form.newPassword) return;
try {
await navigator.clipboard.writeText(form.newPassword);
alert({ alertType: 'success', title: t('workspace.people.changePassword.copiedToClipboard', 'Password copied to clipboard') });
alert({
alertType: "success",
title: t("workspace.people.changePassword.copiedToClipboard", "Password copied to clipboard"),
});
} catch (_error) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.copyFailed', 'Failed to copy password') });
alert({ alertType: "error", title: t("workspace.people.changePassword.copyFailed", "Failed to copy password") });
}
};
const resetState = () => {
setForm({
newPassword: '',
confirmPassword: '',
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
@@ -102,12 +100,15 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
if (!user) return;
if (!form.generateRandom && !form.newPassword.trim()) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordRequired', 'Please enter a new password') });
alert({
alertType: "error",
title: t("workspace.people.changePassword.passwordRequired", "Please enter a new password"),
});
return;
}
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
alert({ alertType: 'error', title: t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') });
alert({ alertType: "error", title: t("workspace.people.changePassword.passwordMismatch", "Passwords do not match") });
return;
}
@@ -123,14 +124,15 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
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 instanceof Error ? error.message : undefined) || t('workspace.people.changePassword.error', 'Failed to update password');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.changePassword.error", "Failed to update password");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -139,8 +141,8 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
useEffect(() => {
if (opened) {
setForm({
newPassword: '',
confirmPassword: '',
newPassword: "",
confirmPassword: "",
generateRandom: false,
sendEmail: false,
includePassword: false,
@@ -157,7 +159,10 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
};
const canEmail = mailEnabled && isValidEmail(user?.username);
const passwordPreview = useMemo(() => form.newPassword && form.generateRandom ? form.newPassword : '', [form.generateRandom, form.newPassword]);
const passwordPreview = useMemo(
() => (form.newPassword && form.generateRandom ? form.newPassword : ""),
[form.generateRandom, form.newPassword],
);
return (
<Modal
@@ -169,13 +174,13 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={handleClose}
size="lg"
disabled={processing}
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
@@ -183,35 +188,39 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
/>
<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')}
{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 })}
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 })}
disabled={processing || disabled || form.generateRandom}
error={!form.generateRandom && form.confirmPassword && form.newPassword !== form.confirmPassword ? t('workspace.people.changePassword.passwordMismatch', 'Passwords do not match') : undefined}
error={
!form.generateRandom && form.confirmPassword && form.newPassword !== form.confirmPassword
? t("workspace.people.changePassword.passwordMismatch", "Passwords do not match")
: undefined
}
/>
<Group justify="space-between">
<Checkbox
label={t('workspace.people.changePassword.generateRandom', 'Generate secure password')}
label={t("workspace.people.changePassword.generateRandom", "Generate secure password")}
checked={form.generateRandom}
disabled={processing || disabled}
onChange={(event) => {
@@ -225,16 +234,11 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
{passwordPreview && (
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{t('workspace.people.changePassword.generatedPreview', 'Generated password:')} <strong>{passwordPreview}</strong>
{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}
>
<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>
@@ -245,19 +249,19 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
<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 })}
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 })}
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 })}
disabled={processing || disabled}
@@ -265,19 +269,28 @@ export default function ChangeUserPasswordModal({ opened, onClose, user, onSucce
{!canEmail && (
<Text size="xs" c="dimmed">
{mailEnabled
? t('workspace.people.changePassword.emailUnavailable', "This user's email is not a valid email address. Notifications are disabled.")
: t('workspace.people.changePassword.smtpDisabled', 'Email notifications require SMTP to be enabled in settings.')}
? t(
"workspace.people.changePassword.emailUnavailable",
"This user's email is not a valid email address. Notifications are disabled.",
)
: t(
"workspace.people.changePassword.smtpDisabled",
"Email notifications require SMTP to be enabled in settings.",
)}
</Text>
)}
{canEmail && !form.includePassword && form.sendEmail && (
<Text size="xs" c="dimmed">
{t('workspace.people.changePassword.notifyOnly', 'An email will be sent without the password, letting the user know an admin changed it.')}
{t(
"workspace.people.changePassword.notifyOnly",
"An email will be sent without the password, letting the user know an admin changed it.",
)}
</Text>
)}
</Stack>
<Button onClick={handleSubmit} loading={processing} fullWidth size="md" disabled={disabled} mt="md">
{t('workspace.people.changePassword.submit', 'Update password')}
{t("workspace.people.changePassword.submit", "Update password")}
</Button>
</Stack>
</div>
@@ -1,25 +1,29 @@
import '@app/components/shared/dividerWithText/DividerWithText.css';
import "@app/components/shared/dividerWithText/DividerWithText.css";
interface TextDividerProps {
text?: string
className?: string
style?: React.CSSProperties
variant?: 'default' | 'subcategory'
respondsToDarkMode?: boolean
opacity?: number
text?: string;
className?: string;
style?: React.CSSProperties;
variant?: "default" | "subcategory";
respondsToDarkMode?: boolean;
opacity?: number;
}
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
const variantClass = variant === 'subcategory' ? 'subcategory' : '';
const themeClass = respondsToDarkMode ? '' : 'force-light';
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as string]: opacity } : style;
export default function DividerWithText({
text,
className = "",
style,
variant = "default",
respondsToDarkMode = true,
opacity,
}: TextDividerProps) {
const variantClass = variant === "subcategory" ? "subcategory" : "";
const themeClass = respondsToDarkMode ? "" : "force-light";
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ["--text-divider-opacity" as string]: opacity } : style;
if (text) {
return (
<div
className={`text-divider ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className={`text-divider ${variantClass} ${themeClass} ${className}`} style={styleWithOpacity}>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
@@ -27,10 +31,5 @@ export default function DividerWithText({ text, className = '', style, variant =
);
}
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
);
return <div className={`h-px my-2.5 ${themeClass} ${className}`} style={styleWithOpacity} />;
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useRef } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Modal,
Stack,
@@ -16,14 +16,14 @@ import {
CloseButton,
Box,
Group,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useNavigate } from 'react-router-dom';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService } from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useNavigate } from "react-router-dom";
interface InviteMembersModalProps {
opened: boolean;
@@ -37,7 +37,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [inviteMode, setInviteMode] = useState<"email" | "direct" | "link">("direct");
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
const actionTakenRef = useRef(false);
@@ -54,26 +54,26 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
authType: 'WEB' as 'WEB' | 'OAUTH2' | 'SAML2',
authType: "WEB" as "WEB" | "OAUTH2" | "SAML2",
forceChange: false,
forceMFA: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
emails: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
email: "",
role: "ROLE_USER",
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
@@ -84,10 +84,7 @@ 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);
@@ -100,7 +97,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
totalUsers: adminData.totalUsers,
});
} catch (error) {
console.error('Failed to fetch data:', error);
console.error("Failed to fetch data:", error);
}
};
fetchData();
@@ -109,12 +106,12 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
const roleOptions = [
{
value: 'ROLE_USER',
label: t('workspace.people.roleDescriptions.user', 'User'),
value: "ROLE_USER",
label: t("workspace.people.roleDescriptions.user", "User"),
},
{
value: 'ROLE_ADMIN',
label: t('workspace.people.roleDescriptions.admin', 'Admin'),
value: "ROLE_ADMIN",
label: t("workspace.people.roleDescriptions.admin", "Admin"),
},
];
@@ -125,13 +122,13 @@ 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') });
if (inviteForm.authType === "WEB" && !inviteForm.password) {
alert({ alertType: "error", title: t("workspace.people.addMember.passwordRequired", "Password is required") });
return;
}
@@ -146,25 +143,25 @@ 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
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: 'WEB',
authType: "WEB",
forceChange: false,
forceMFA: false,
});
} catch (error: unknown) {
console.error('Failed to invite user:', error);
console.error("Failed to invite user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
? 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);
}
@@ -172,7 +169,7 @@ 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;
}
@@ -187,39 +184,43 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
if (response.successCount > 0) {
// Show success message
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` })
alertType: "success",
title: t("workspace.people.emailInvite.success", {
count: response.successCount,
defaultValue: `Successfully invited ${response.successCount} user(s)`,
}),
});
// Show warning if there were partial failures
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialFailure', 'Some invites failed'),
body: response.errors
alertType: "warning",
title: t("workspace.people.emailInvite.partialFailure", "Some invites failed"),
body: response.errors,
});
}
onClose();
onSuccess?.();
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
alertType: "error",
title: t("workspace.people.emailInvite.allFailed", "Failed to invite users"),
body: response.errors || response.error,
});
}
} catch (error: unknown) {
console.error('Failed to invite users:', error);
console.error("Failed to invite users:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.emailInvite.error", "Failed to send invites");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -239,14 +240,18 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
actionTakenRef.current = true;
setGeneratedInviteLink(response.inviteUrl);
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') });
alert({
alertType: "success",
title: t("workspace.people.inviteLink.emailSent", "Invite link generated and sent via email"),
});
}
} catch (error: unknown) {
console.error('Failed to generate invite link:', error);
console.error("Failed to generate invite link:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.inviteLink.error", "Failed to generate invite link");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -258,24 +263,24 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
actionTakenRef.current = false;
}
setGeneratedInviteLink(null);
setInviteMode('direct');
setInviteMode("direct");
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
username: "",
password: "",
role: "ROLE_USER",
teamId: undefined,
authType: 'WEB',
authType: "WEB",
forceChange: false,
forceMFA: false,
});
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
emails: "",
role: "ROLE_USER",
teamId: undefined,
});
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
email: "",
role: "ROLE_USER",
teamId: undefined,
expiryHours: 72,
sendEmail: false,
@@ -285,13 +290,13 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
const handleGoToPlan = () => {
handleClose();
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
};
const handlePrimaryAction = () => {
if (inviteMode === 'email') {
if (inviteMode === "email") {
handleEmailInvite();
} else if (inviteMode === 'link') {
} else if (inviteMode === "link") {
handleGenerateInviteLink();
} else {
handleInviteUser();
@@ -313,53 +318,60 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
onClick={handleClose}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<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')}
{t("workspace.people.inviteMembers.label", "Invite Members")}
</Text>
{inviteMode === 'email' && (
{inviteMode === "email" && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
{t(
"workspace.people.inviteMembers.subtitle",
"Type or paste in emails below, separated by commas. Your workspace will be billed by members.",
)}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Paper
withBorder
p="sm"
bg={licenseInfo.availableSlots === 0 ? "var(--mantine-color-red-light)" : "var(--mantine-color-blue-light)"}
>
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<LocalIcon icon={licenseInfo.availableSlots > 0 ? "info" : "warning"} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
? t("workspace.people.license.slotsAvailable", {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
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 && (
<Button size="xs" variant="light" onClick={handleGoToPlan}>
{t('workspace.people.actions.upgrade', 'Upgrade')}
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
)}
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
{t("workspace.people.license.currentUsage", {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`,
})}
</Text>
</Stack>
@@ -368,7 +380,10 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
label={t(
"workspace.people.inviteMode.emailDisabled",
"Email invites require SMTP configuration and mail.enableInvites=true in settings",
)}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
@@ -378,21 +393,21 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setInviteMode(value as "email" | "direct" | "link");
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
label: t("workspace.people.inviteMode.username", "Username"),
value: "direct",
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
label: t("workspace.people.inviteMode.link", "Link"),
value: "link",
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
label: t("workspace.people.inviteMode.email", "Email"),
value: "email",
disabled: !config?.enableEmailInvites,
},
]}
@@ -402,25 +417,28 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
{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 })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
description={t(
"workspace.people.inviteLink.emailDescription",
"If provided, the link will be tied to this email address",
)}
/>
<Select
label={t('workspace.people.addMember.role')}
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
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')}
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 })}
@@ -428,7 +446,7 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
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 })}
@@ -437,8 +455,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
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 })}
/>
@@ -448,30 +469,34 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Text size="sm" fw={500}>
{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);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
alert({
alertType: "success",
title: t("workspace.people.inviteLink.copied", "Link copied to clipboard!"),
});
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
const textArea = document.createElement("textarea");
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.execCommand("copy");
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
alert({
alertType: "success",
title: t("workspace.people.inviteLink.copied", "Link copied to clipboard!"),
});
}
}}
>
@@ -485,26 +510,26 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
{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 })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
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')}
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 })}
@@ -515,11 +540,11 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
{inviteMode === "direct" && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
label={t("workspace.people.addMember.username")}
placeholder={t("workspace.people.addMember.usernamePlaceholder")}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
@@ -528,37 +553,45 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
{/* 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') },
...(config?.enableOAuth ? [{ value: 'OAUTH2', label: t('workspace.people.authType.oauth', 'OAuth2') }] : []),
...(config?.enableSaml ? [{ value: 'SAML2', label: t('workspace.people.authType.saml', 'SAML2') }] : []),
{ value: "WEB", label: t("workspace.people.authType.password", "Password") },
...(config?.enableOAuth
? [{ value: "OAUTH2", label: t("workspace.people.authType.oauth", "OAuth2") }]
: []),
...(config?.enableSaml ? [{ value: "SAML2", label: t("workspace.people.authType.saml", "SAML2") }] : []),
]}
value={inviteForm.authType}
onChange={(value) => setInviteForm({ ...inviteForm, authType: (value as 'WEB' | 'OAUTH2' | 'SAML2') || 'WEB' })}
onChange={(value) =>
setInviteForm({ ...inviteForm, authType: (value as "WEB" | "OAUTH2" | "SAML2") || "WEB" })
}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
description={inviteForm.authType !== 'WEB' ? t('workspace.people.authType.ssoDescription', 'User will authenticate via SSO provider') : undefined}
description={
inviteForm.authType !== "WEB"
? t("workspace.people.authType.ssoDescription", "User will authenticate via SSO provider")
: undefined
}
/>
)}
{/* Password field - only required for WEB auth type */}
{inviteForm.authType === 'WEB' && (
{inviteForm.authType === "WEB" && (
<>
<TextInput
label={t('workspace.people.addMember.password')}
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 })}
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 })}
/>
<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 })}
/>
@@ -566,15 +599,15 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
)}
<Select
label={t('workspace.people.addMember.role')}
label={t("workspace.people.addMember.role")}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
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')}
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 })}
@@ -585,22 +618,15 @@ export default function InviteMembersModal({ opened, onClose, onSuccess }: Invit
)}
{/* Action Button */}
<Button
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
<Button onClick={handlePrimaryAction} loading={!hasNoSlots && processing} fullWidth size="md" mt="md">
{inviteMode === "email"
? t("workspace.people.emailInvite.submit", "Send Invites")
: inviteMode === "link"
? t("workspace.people.inviteLink.submit", "Generate Link")
: t("workspace.people.addMember.submit")}
</Button>
</Stack>
</Box>
</Modal>
);
}
@@ -1,7 +1,15 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '@app/constants/app';
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { BASE_PATH } from "@app/constants/app";
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
type ImageSlide = {
src: string;
alt?: string;
cornerModelUrl?: string;
title?: string;
subtitle?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
function LoginRightCarousel({
imageSlides = [],
@@ -9,10 +17,10 @@ function LoginRightCarousel({
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[]
showBackground?: boolean
initialSeconds?: number
slideSeconds?: number
imageSlides?: ImageSlide[];
showBackground?: boolean;
initialSeconds?: number;
slideSeconds?: number;
}) {
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
@@ -25,9 +33,12 @@ function LoginRightCarousel({
useEffect(() => {
if (totalSlides <= 1) return;
const timeout = setTimeout(() => {
setIndex((i) => (i + 1) % totalSlides);
}, durationsMs[index] ?? slideSeconds * 1000);
const timeout = setTimeout(
() => {
setIndex((i) => (i + 1) % totalSlides);
},
durationsMs[index] ?? slideSeconds * 1000,
);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
@@ -36,8 +47,8 @@ function LoginRightCarousel({
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
@@ -54,7 +65,7 @@ function LoginRightCarousel({
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = 'translateY(-2rem)';
el.style.transform = "translateY(-2rem)";
}
raf = requestAnimationFrame(tick);
};
@@ -66,29 +77,29 @@ function LoginRightCarousel({
<img
ref={imgRef}
src={src}
alt={alt ?? 'Carousel slide'}
alt={alt ?? "Carousel slide"}
style={{
maxWidth: '86%',
maxHeight: '78%',
objectFit: 'contain',
borderRadius: '18px',
background: 'transparent',
transform: 'translateY(-2rem)',
transition: 'transform 80ms ease-out',
willChange: 'transform',
transformOrigin: '50% 50%',
maxWidth: "86%",
maxHeight: "78%",
objectFit: "contain",
borderRadius: "18px",
background: "transparent",
transform: "translateY(-2rem)",
transition: "transform 80ms ease-out",
willChange: "transform",
transformOrigin: "50% 50%",
}}
/>
);
}
return (
<div style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
<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" }}
/>
)}
@@ -97,40 +108,61 @@ function LoginRightCarousel({
<div
key={s.src}
style={{
position: 'absolute',
position: "absolute",
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'opacity 600ms ease',
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 600ms ease",
opacity: index === idx ? 1 : 0,
perspective: '900px',
perspective: "900px",
}}
>
{(s.title || s.subtitle) && (
<div style={{ position: 'absolute', bottom: 24 + 32, left: 0, right: 0, textAlign: 'center', padding: '0 2rem', width: '100%' }}>
<div
style={{
position: "absolute",
bottom: 24 + 32,
left: 0,
right: 0,
textAlign: "center",
padding: "0 2rem",
width: "100%",
}}
>
{s.title && (
<div style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', textShadow: '0 2px 6px rgba(0,0,0,0.25)', marginBottom: 6 }}>{s.title}</div>
<div
style={{
fontSize: 20,
fontWeight: 800,
color: "#ffffff",
textShadow: "0 2px 6px rgba(0,0,0,0.25)",
marginBottom: 6,
}}
>
{s.title}
</div>
)}
{s.subtitle && (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.92)', textShadow: '0 1px 4px rgba(0,0,0,0.25)' }}>{s.subtitle}</div>
<div style={{ fontSize: 13, color: "rgba(255,255,255,0.92)", textShadow: "0 1px 4px rgba(0,0,0,0.25)" }}>
{s.subtitle}
</div>
)}
</div>
)}
<TiltImage src={s.src} alt={s.alt} enabled={index === idx && !!s.followMouseTilt} maxDeg={s.tiltMaxDeg ?? 6} />
</div>
))}
{/* Dot navigation */}
<div
style={{
position: 'absolute',
position: "absolute",
bottom: 16,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
display: "flex",
justifyContent: "center",
gap: 10,
zIndex: 2,
}}
@@ -141,14 +173,14 @@ function LoginRightCarousel({
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
style={{
width: '10px',
height: '12px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
backgroundColor: i === index ? '#ffffff' : 'rgba(255,255,255,0.5)',
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
display: 'block',
width: "10px",
height: "12px",
borderRadius: "50%",
border: "none",
cursor: "pointer",
backgroundColor: i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "block",
flexShrink: 0,
}}
/>
@@ -1,16 +1,14 @@
import React, { useState } from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService from '@app/services/licenseService';
import { alert } from '@app/components/toast';
import React, { useState } from "react";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import { alert } from "@app/components/toast";
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({ returnUrl = window.location.href }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -22,24 +20,21 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
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');
window.open(response.url, "_blank");
setLoading(false);
} catch (error: unknown) {
console.error('Failed to open billing portal:', error);
console.error("Failed to open billing portal:", error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: (error instanceof Error ? error.message : undefined) || 'Please try again or contact support.',
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body: (error instanceof Error ? error.message : undefined) || "Please try again or contact support.",
});
setLoading(false);
}
@@ -47,7 +42,7 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t('billing.manageBilling', 'Manage Billing')}
{t("billing.manageBilling", "Manage Billing")}
</Button>
);
};
@@ -1,18 +1,14 @@
import React from 'react';
import { Button, ButtonProps } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useUpdateSeats } from '@app/contexts/UpdateSeatsContext';
import React from "react";
import { Button, ButtonProps } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext";
interface UpdateSeatsButtonProps extends Omit<ButtonProps, 'onClick' | 'loading'> {
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();
@@ -24,13 +20,8 @@ export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
};
return (
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t('billing.updateSeats', 'Update Seats')}
<Button variant="outline" onClick={handleClick} loading={isLoading} {...buttonProps}>
{t("billing.updateSeats", "Update Seats")}
</Button>
);
};
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import React, { useState, useEffect } from "react";
import { Modal, Button, Text, Alert, Loader, Stack, Group, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface UpdateSeatsModalProps {
opened: boolean;
@@ -14,7 +14,7 @@ interface UpdateSeatsModalProps {
}
type UpdateState = {
status: 'idle' | 'loading' | 'error';
status: "idle" | "loading" | "error";
error?: string;
};
@@ -28,63 +28,60 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
onUpdateSeats,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<UpdateState>({ status: 'idle' });
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [newSeatCount, setNewSeatCount] = useState<number>(minimumSeats);
// Reset seat count when modal opens
useEffect(() => {
if (opened) {
setNewSeatCount(minimumSeats);
setState({ status: 'idle' });
setState({ status: "idle" });
}
}, [opened, minimumSeats]);
const handleUpdateSeats = async () => {
if (!onUpdateSeats) {
setState({
status: 'error',
error: 'Update function not provided',
status: "error",
error: "Update function not provided",
});
return;
}
if (newSeatCount < minimumSeats) {
setState({
status: 'error',
error: t(
'billing.seatCountTooLow',
'Seat count must be at least {{minimum}} (current number of users)',
{ minimum: minimumSeats }
),
status: "error",
error: t("billing.seatCountTooLow", "Seat count must be at least {{minimum}} (current number of users)", {
minimum: minimumSeats,
}),
});
return;
}
if (newSeatCount === currentSeats) {
setState({
status: 'error',
error: t('billing.seatCountUnchanged', 'Please select a different seat count'),
status: "error",
error: t("billing.seatCountUnchanged", "Please select a different seat count"),
});
return;
}
try {
setState({ status: 'loading' });
setState({ status: "loading" });
// Call the update function (will call manage-billing)
const portalUrl = await onUpdateSeats(newSeatCount);
// Redirect to Stripe billing portal
console.log('Redirecting to Stripe billing portal:', portalUrl);
console.log("Redirecting to Stripe billing portal:", portalUrl);
window.location.href = portalUrl;
// Note: No need to call onSuccess here since we're redirecting
// The return flow will handle success notification
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to update seat count';
const errorMessage = err instanceof Error ? err.message : "Failed to update seat count";
setState({
status: 'error',
status: "error",
error: errorMessage,
});
onError?.(errorMessage);
@@ -92,18 +89,18 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
};
const handleClose = () => {
setState({ status: 'idle' });
setState({ status: "idle" });
setNewSeatCount(currentSeats);
onClose();
};
const renderContent = () => {
if (state.status === 'loading') {
if (state.status === "loading") {
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('billing.preparingUpdate', 'Preparing seat update...')}
{t("billing.preparingUpdate", "Preparing seat update...")}
</Text>
</Stack>
);
@@ -111,8 +108,8 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
return (
<Stack gap="lg">
{state.status === 'error' && (
<Alert color="red" title={t('common.error', 'Error')}>
{state.status === "error" && (
<Alert color="red" title={t("common.error", "Error")}>
{state.error}
</Alert>
)}
@@ -120,7 +117,7 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
<Stack gap="md" mt="md">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.currentSeats', 'Current Seats')}:
{t("billing.currentSeats", "Current Seats")}:
</Text>
<Text size="sm" fw={600}>
{currentSeats}
@@ -128,53 +125,47 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.minimumSeats', 'Minimum Seats')}:
{t("billing.minimumSeats", "Minimum Seats")}:
</Text>
<Text size="sm" c="dimmed">
{minimumSeats} {t('billing.basedOnUsers', '(current users)')}
{minimumSeats} {t("billing.basedOnUsers", "(current users)")}
</Text>
</Group>
</Stack>
<NumberInput
label={t('billing.newSeatCount', 'New Seat Count')}
description={t(
'billing.newSeatCountDescription',
'Select the number of seats for your enterprise license'
)}
label={t("billing.newSeatCount", "New Seat Count")}
description={t("billing.newSeatCountDescription", "Select the number of seats for your enterprise license")}
value={newSeatCount}
onChange={(value) => setNewSeatCount(typeof value === 'number' ? value : minimumSeats)}
onChange={(value) => setNewSeatCount(typeof value === "number" ? value : minimumSeats)}
min={minimumSeats}
max={10000}
step={1}
size="md"
styles={{
input: {
fontSize: '1.5rem',
fontSize: "1.5rem",
fontWeight: 500,
textAlign: 'center',
textAlign: "center",
},
}}
/>
<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',
'You will be redirected to Stripe\'s billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.'
"billing.stripePortalRedirect",
"You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.",
)}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={handleClose}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleUpdateSeats}
disabled={newSeatCount === currentSeats || newSeatCount < minimumSeats}
>
{t('billing.updateSeats', 'Update Seats')}
<Button onClick={handleUpdateSeats} disabled={newSeatCount === currentSeats || newSeatCount < minimumSeats}>
{t("billing.updateSeats", "Update Seats")}
</Button>
</Group>
</Stack>
@@ -187,7 +178,7 @@ const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('billing.updateEnterpriseSeats', 'Update Enterprise Seats')}
{t("billing.updateEnterpriseSeats", "Update Enterprise Seats")}
</Text>
}
size="md"
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@core/constants/routes';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import {
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
@@ -11,11 +11,11 @@ import {
type UpgradeBannerTestPayload,
type UpgradeBannerTestScenario,
UPGRADE_BANNER_ALERT_EVENT,
} from '@core/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage';
} from "@core/constants/events";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { isOnboardingCompleted } from "@core/components/onboarding/orchestrator/onboardingStorage";
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
const FRIENDLY_LAST_SEEN_KEY = "upgradeBannerFriendlyLastShownAt";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
@@ -40,7 +40,7 @@ const UpgradeBanner: React.FC = () => {
} = useServerExperience();
const onboardingComplete = isOnboardingCompleted();
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
if (!lastShownRaw) return false;
const lastShown = parseInt(lastShownRaw, 10);
@@ -51,7 +51,7 @@ const UpgradeBanner: React.FC = () => {
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
useEffect(() => {
if (!isDev || typeof window === 'undefined') {
if (!isDev || typeof window === "undefined") {
return;
}
@@ -59,7 +59,7 @@ const UpgradeBanner: React.FC = () => {
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
setTestScenario(detail?.scenario ?? null);
if (detail?.scenario === 'friendly') {
if (detail?.scenario === "friendly") {
setFriendlyVisible(true);
} else if (!detail?.scenario) {
setFriendlyVisible(false);
@@ -75,82 +75,52 @@ const UpgradeBanner: React.FC = () => {
const isAdmin = configIsAdmin;
const scenario = isDev ? testScenario : null;
const scenarioIsFriendly = scenario === 'friendly';
const scenarioIsUrgentUser = scenario === 'urgent-user';
const scenarioIsFriendly = scenario === "friendly";
const scenarioIsUrgentUser = scenario === "urgent-user";
const userCountKnown = typeof totalUsers === 'number';
const userCountKnown = typeof totalUsers === "number";
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
const derivedIsAdmin = scenarioProvidesInfo
? scenarioKey.includes('admin')
: isAdmin;
const derivedHasPaidLicense =
scenarioKey === 'licensed'
? true
: scenarioKey === 'unknown'
? hasPaidLicense
: false;
const derivedIsUnderLimit = scenarioProvidesInfo
? scenarioKey.includes('under-limit')
: isUnderLimit === true;
const derivedIsOverLimit = scenarioProvidesInfo
? scenarioKey.includes('over-limit')
: isOverLimit === true;
const 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 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 effectiveIsUnderLimit = scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
const effectiveIsOverLimit = scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
const isDerivedAdmin = scenario
? !scenarioIsUrgentUser
: scenarioKey === 'login-user-over-limit-no-license'
: scenarioKey === "login-user-over-limit-no-license"
? false
: 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'),
effectiveTotalUsersLoaded &&
(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)
: Boolean(
shouldShowUrgentBase &&
!licenseLoading,
);
: Boolean(shouldShowUrgentBase && !licenseLoading);
useEffect(() => {
if (scenario === 'friendly') {
if (scenario === "friendly") {
return;
}
@@ -159,7 +129,7 @@ const UpgradeBanner: React.FC = () => {
return;
}
if (friendlyVisible || typeof window === 'undefined' || userCountLoading) {
if (friendlyVisible || typeof window === "undefined" || userCountLoading) {
return;
}
@@ -171,36 +141,32 @@ const UpgradeBanner: React.FC = () => {
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
useEffect(() => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
const detail = shouldEvaluateUrgent
? {
active: true,
audience: effectiveIsAdmin ? 'admin' : 'user',
audience: effectiveIsAdmin ? "admin" : "user",
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
}
: { active: false };
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
);
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 } }),
);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail: { active: false } }));
}
};
}, []);
const recordFriendlyLastShown = useCallback(() => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
@@ -211,12 +177,12 @@ const UpgradeBanner: React.FC = () => {
const hideBanner = () => setFriendlyVisible(false);
const navigateFallback = () => {
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
hideBanner();
};
try {
openCheckout('server', {
openCheckout("server", {
minimumSeats: 1,
onSuccess: () => {
hideBanner();
@@ -226,7 +192,7 @@ 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;
}
@@ -241,7 +207,7 @@ const UpgradeBanner: React.FC = () => {
};
const handleSeeInfo = () => {
if (typeof window === 'undefined' || !effectiveIsAdmin) {
if (typeof window === "undefined" || !effectiveIsAdmin) {
return;
}
@@ -255,9 +221,7 @@ 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 = () => {
@@ -265,23 +229,17 @@ 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"
@@ -298,18 +256,12 @@ 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;
}
@@ -318,12 +270,9 @@ const UpgradeBanner: React.FC = () => {
{friendlyVisible && (
<InfoBanner
icon="stars-rounded"
title={t('upgradeBanner.title', 'Upgrade to Server Plan')}
message={t(
'upgradeBanner.message',
'Get the most out of Stirling PDF with unlimited users and advanced features.',
)}
buttonText={t('upgradeBanner.upgradeButton', 'Upgrade Now')}
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t("upgradeBanner.message", "Get the most out of Stirling PDF with unlimited users and advanced features.")}
buttonText={t("upgradeBanner.upgradeButton", "Upgrade Now")}
buttonIcon="upgrade-rounded"
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
import { useEffect } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function UpgradeBannerInitializer() {
const { setBanner } = useBanner();
@@ -14,4 +14,3 @@ export function UpgradeBannerInitializer() {
return null;
}
@@ -1,6 +1,6 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EnterpriseRequiredBannerProps {
show: boolean;
@@ -18,20 +18,20 @@ export default function EnterpriseRequiredBanner({ show, featureName }: Enterpri
return (
<Alert
icon={<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />}
title={t('admin.settings.enterpriseRequired.title', 'Enterprise License Required')}
title={t("admin.settings.enterpriseRequired.title", "Enterprise License Required")}
color="yellow"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-yellow-6)'
}
borderLeft: "4px solid var(--mantine-color-yellow-6)",
},
}}
>
<Text size="sm">
{t(
'admin.settings.enterpriseRequired.message',
'An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.',
{ featureName }
"admin.settings.enterpriseRequired.message",
"An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.",
{ featureName },
)}
</Text>
</Alert>
@@ -1,7 +1,7 @@
import { Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
import { Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useNavigate } from "react-router-dom";
export function OverviewHeader() {
const { t } = useTranslation();
@@ -11,19 +11,21 @@ export function OverviewHeader() {
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
navigate("/login");
} catch (error) {
console.error('Logout error:', error);
console.error("Logout error:", error);
}
};
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 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">
@@ -1,24 +1,28 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useConfigNavSections as useCoreConfigNavSections, createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import AdminStorageSharingSection from '@app/components/shared/config/configSections/AdminStorageSharingSection';
import ApiKeys from '@app/components/shared/config/configSections/ApiKeys';
import AccountSection from '@app/components/shared/config/configSections/AccountSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import React from "react";
import { useTranslation } from "react-i18next";
import {
useConfigNavSections as useCoreConfigNavSections,
createConfigNavSections as createCoreConfigNavSections,
ConfigNavSection,
} from "@core/components/shared/config/configNavSections";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
import TeamsSection from "@app/components/shared/config/configSections/TeamsSection";
import AdminGeneralSection from "@app/components/shared/config/configSections/AdminGeneralSection";
import AdminSecuritySection from "@app/components/shared/config/configSections/AdminSecuritySection";
import AdminConnectionsSection from "@app/components/shared/config/configSections/AdminConnectionsSection";
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
import AdminDatabaseSection from "@app/components/shared/config/configSections/AdminDatabaseSection";
import AdminAdvancedSection from "@app/components/shared/config/configSections/AdminAdvancedSection";
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
/**
* Hook version of proprietary config nav sections with proper i18n support
@@ -26,7 +30,7 @@ import GeneralSection from '@app/components/shared/config/configSections/General
export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
loginEnabled: boolean = false,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -34,18 +38,18 @@ 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) {
preferencesSection.items.push({
key: 'account',
label: t('account.accountSettings', 'Account'),
icon: 'person-rounded',
component: <AccountSection />
key: "account",
label: t("account.accountSettings", "Account"),
icon: "person-rounded",
component: <AccountSection />,
});
}
}
@@ -53,162 +57,162 @@ 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({
title: t('settings.workspace.title', 'Workspace'),
title: t("settings.workspace.title", "Workspace"),
items: [
{
key: 'people',
label: t('settings.workspace.people', 'People'),
icon: 'group-rounded',
key: "people",
label: t("settings.workspace.people", "People"),
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'teams',
label: t('settings.workspace.teams', 'Teams'),
icon: 'groups-rounded',
key: "teams",
label: t("settings.workspace.teams", "Teams"),
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Configuration
sections.push({
title: t('settings.configuration.title', 'Configuration'),
title: t("settings.configuration.title", "Configuration"),
items: [
{
key: 'adminGeneral',
label: t('settings.configuration.systemSettings', 'System Settings'),
icon: 'settings-rounded',
key: "adminGeneral",
label: t("settings.configuration.systemSettings", "System Settings"),
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminFeatures',
label: t('settings.configuration.features', 'Features'),
icon: 'extension-rounded',
key: "adminFeatures",
label: t("settings.configuration.features", "Features"),
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminStorageSharing',
label: t('settings.configuration.storageSharing', 'File Storage & Sharing'),
icon: 'storage-rounded',
key: "adminStorageSharing",
label: t("settings.configuration.storageSharing", "File Storage & Sharing"),
icon: "storage-rounded",
component: <AdminStorageSharingSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
badge: t('toolPanel.alpha', 'Alpha'),
badgeColor: 'orange',
badge: t("toolPanel.alpha", "Alpha"),
badgeColor: "orange",
},
{
key: 'adminEndpoints',
label: t('settings.configuration.endpoints', 'Endpoints'),
icon: 'api-rounded',
key: "adminEndpoints",
label: t("settings.configuration.endpoints", "Endpoints"),
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminDatabase',
label: t('settings.configuration.database', 'Database'),
icon: 'storage-rounded',
key: "adminDatabase",
label: t("settings.configuration.database", "Database"),
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminAdvanced',
label: t('settings.configuration.advanced', 'Advanced'),
icon: 'tune-rounded',
key: "adminAdvanced",
label: t("settings.configuration.advanced", "Advanced"),
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Security & Authentication
sections.push({
title: t('settings.securityAuth.title', 'Security & Authentication'),
title: t("settings.securityAuth.title", "Security & Authentication"),
items: [
{
key: 'adminSecurity',
label: t('settings.securityAuth.security', 'Security'),
icon: 'shield-rounded',
key: "adminSecurity",
label: t("settings.securityAuth.security", "Security"),
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminConnections',
label: t('settings.securityAuth.connections', 'Connections'),
icon: 'link-rounded',
key: "adminConnections",
label: t("settings.securityAuth.connections", "Connections"),
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: t('settings.licensingAnalytics.title', 'Licensing & Analytics'),
title: t("settings.licensingAnalytics.title", "Licensing & Analytics"),
items: [
{
key: 'adminPlan',
label: t('settings.licensingAnalytics.plan', 'Plan'),
icon: 'star-rounded',
key: "adminPlan",
label: t("settings.licensingAnalytics.plan", "Plan"),
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminAudit',
label: t('settings.licensingAnalytics.audit', 'Audit'),
icon: 'fact-check-rounded',
key: "adminAudit",
label: t("settings.licensingAnalytics.audit", "Audit"),
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'),
icon: 'analytics-rounded',
key: "adminUsage",
label: t("settings.licensingAnalytics.usageAnalytics", "Usage Analytics"),
icon: "analytics-rounded",
component: <AdminUsageSection />,
disabled: !runningEE || requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip
disabledTooltip: requiresLogin ? enableLoginTooltip : requiresEnterpriseTooltip,
},
],
});
// Policies & Privacy
sections.push({
title: t('settings.policiesPrivacy.title', 'Policies & Privacy'),
title: t("settings.policiesPrivacy.title", "Policies & Privacy"),
items: [
{
key: 'adminLegal',
label: t('settings.policiesPrivacy.legal', 'Legal'),
icon: 'gavel-rounded',
key: "adminLegal",
label: t("settings.policiesPrivacy.legal", "Legal"),
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: 'adminPrivacy',
label: t('settings.policiesPrivacy.privacy', 'Privacy'),
icon: 'visibility-rounded',
key: "adminPrivacy",
label: t("settings.policiesPrivacy.privacy", "Privacy"),
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
],
});
@@ -217,13 +221,13 @@ export const useConfigNavSections = (
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: t('settings.developer.title', 'Developer'),
title: t("settings.developer.title", "Developer"),
items: [
{
key: 'api-keys',
label: t('settings.developer.apiKeys', 'API Keys'),
icon: 'key-rounded',
component: <ApiKeys />
key: "api-keys",
label: t("settings.developer.apiKeys", "API Keys"),
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
@@ -243,26 +247,26 @@ export const useConfigNavSections = (
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: 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);
// 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) {
preferencesSection.items.push({
key: 'account',
label: 'Account',
icon: 'person-rounded',
component: <AccountSection />
key: "account",
label: "Account",
icon: "person-rounded",
component: <AccountSection />,
});
}
}
@@ -273,147 +277,147 @@ export const createConfigNavSections = (
// Workspace
sections.push({
title: 'Workspace',
title: "Workspace",
items: [
{
key: 'people',
label: 'People',
icon: 'group-rounded',
key: "people",
label: "People",
icon: "group-rounded",
component: <PeopleSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'teams',
label: 'Teams',
icon: 'groups-rounded',
key: "teams",
label: "Teams",
icon: "groups-rounded",
component: <TeamsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Configuration
sections.push({
title: 'Configuration',
title: "Configuration",
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
key: "adminGeneral",
label: "System Settings",
icon: "settings-rounded",
component: <AdminGeneralSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
key: "adminFeatures",
label: "Features",
icon: "extension-rounded",
component: <AdminFeaturesSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
key: "adminEndpoints",
label: "Endpoints",
icon: "api-rounded",
component: <AdminEndpointsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
key: "adminDatabase",
label: "Database",
icon: "storage-rounded",
component: <AdminDatabaseSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
key: "adminAdvanced",
label: "Advanced",
icon: "tune-rounded",
component: <AdminAdvancedSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
title: "Security & Authentication",
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
key: "adminSecurity",
label: "Security",
icon: "shield-rounded",
component: <AdminSecuritySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
key: "adminConnections",
label: "Connections",
icon: "link-rounded",
component: <AdminConnectionsSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
title: "Licensing & Analytics",
items: [
{
key: 'adminPlan',
label: 'Plan',
icon: 'star-rounded',
key: "adminPlan",
label: "Plan",
icon: "star-rounded",
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
key: "adminAudit",
label: "Audit",
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',
label: 'Usage Analytics',
icon: 'analytics-rounded',
key: "adminUsage",
label: "Usage Analytics",
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",
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
title: "Policies & Privacy",
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
key: "adminLegal",
label: "Legal",
icon: "gavel-rounded",
component: <AdminLegalSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
key: "adminPrivacy",
label: "Privacy",
icon: "visibility-rounded",
component: <AdminPrivacySection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
disabledTooltip: requiresLogin ? "Enable login mode first" : undefined,
},
],
});
@@ -422,13 +426,13 @@ export const createConfigNavSections = (
// Add Developer section if login is enabled
if (loginEnabled) {
const developerSection: ConfigNavSection = {
title: 'Developer',
title: "Developer",
items: [
{
key: 'api-keys',
label: 'API Keys',
icon: 'key-rounded',
component: <ApiKeys />
key: "api-keys",
label: "API Keys",
icon: "key-rounded",
component: <ApiKeys />,
},
],
};
@@ -442,4 +446,4 @@ export const createConfigNavSections = (
};
// Re-export types for convenience
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from "@core/components/shared/config/configNavSections";
@@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert as showToast } from '@app/components/toast';
import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { QRCodeSVG } from 'qrcode.react';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { BASE_PATH } from '@app/constants/app';
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Box, Group, Modal, Paper, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert as showToast } from "@app/components/toast";
import { useAuth } from "@app/auth/UseSession";
import { accountService } from "@app/services/accountService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { QRCodeSVG } from "qrcode.react";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { BASE_PATH } from "@app/constants/app";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
const AccountSection: React.FC = () => {
const { t } = useTranslation();
@@ -18,26 +18,26 @@ const AccountSection: React.FC = () => {
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState('');
const [newUsername, setNewUsername] = useState('');
const [usernameError, setUsernameError] = useState('');
const [currentPasswordForUsername, setCurrentPasswordForUsername] = useState("");
const [newUsername, setNewUsername] = useState("");
const [usernameError, setUsernameError] = useState("");
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaDisableCode, setMfaDisableCode] = useState('');
const [mfaError, setMfaError] = useState('');
const [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(() => {
@@ -46,15 +46,15 @@ const AccountSection: React.FC = () => {
}, [user?.app_metadata]);
const normalizedAuthType = useMemo(
() => (user?.authenticationType ?? authTypeFromMetadata ?? '').toLowerCase(),
[authTypeFromMetadata, user?.authenticationType]
() => (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');
window.location.assign("/login");
}, []);
const handleLogout = useCallback(async () => {
@@ -65,41 +65,44 @@ 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;
}
try {
setPasswordSubmitting(true);
setPasswordError('');
setPasswordError("");
await accountService.changePassword(currentPassword, newPassword);
showToast({
alertType: 'success',
title: t('settings.security.password.success', 'Password updated successfully. Please sign in again.'),
alertType: "success",
title: t("settings.security.password.success", "Password updated successfully. Please sign in again."),
});
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setPasswordError(
axiosError.response?.data?.message ||
t('settings.security.password.error', 'Unable to update password. Please verify your current password and try again.')
t(
"settings.security.password.error",
"Unable to update password. Please verify your current password and try again.",
),
);
} finally {
setPasswordSubmitting(false);
@@ -110,13 +113,16 @@ const AccountSection: React.FC = () => {
const fetchAccountData = async () => {
setChangeButtonDisabled(true);
try {
const data = await accountService.getAccountData().then((data) => data).finally(() => {
setChangeButtonDisabled(false);
});
const data = await accountService
.getAccountData()
.then((data) => data)
.finally(() => {
setChangeButtonDisabled(false);
});
setMfaEnabled(data.mfaEnabled ?? false);
} catch {
// ignore fetch errors for account data
console.warn('Failed to fetch account data');
console.warn("Failed to fetch account data");
} finally {
setChangeButtonDisabled(false);
}
@@ -127,8 +133,8 @@ const AccountSection: React.FC = () => {
const handleStartMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError('');
setMfaSetupCode('');
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
setMfaSetupModalOpen(true);
@@ -136,7 +142,7 @@ 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);
@@ -147,117 +153,117 @@ 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 {
setMfaLoading(true);
setMfaError('');
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setMfaEnabled(true);
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
setMfaSetupCode("");
showToast({
alertType: 'success',
title: t('account.mfa.enabled', 'Two-factor authentication enabled.'),
alertType: "success",
title: t("account.mfa.enabled", "Two-factor authentication enabled."),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t('account.mfa.enableFailed', 'Unable to enable two-factor authentication. Check the code and try again.')
t("account.mfa.enableFailed", "Unable to enable two-factor authentication. Check the code and try again."),
);
} finally {
setMfaLoading(false);
}
},
[mfaSetupCode, t]
[mfaSetupCode, t],
);
const handleDisableMfa = useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (!mfaDisableCode.trim()) {
setMfaError(t('account.mfa.codeRequired', 'Enter the authentication code to continue.'));
setMfaError(t("account.mfa.codeRequired", "Enter the authentication code to continue."));
return;
}
try {
setMfaLoading(true);
setMfaError('');
setMfaError("");
await accountService.disableMfa(mfaDisableCode.trim());
setMfaEnabled(false);
setMfaDisableModalOpen(false);
setMfaDisableCode('');
setMfaDisableCode("");
showToast({
alertType: 'success',
title: t('account.mfa.disabled', 'Two-factor authentication disabled.'),
alertType: "success",
title: t("account.mfa.disabled", "Two-factor authentication disabled."),
});
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
t('account.mfa.disableFailed', 'Unable to disable two-factor authentication. Check the code and try again.')
t("account.mfa.disableFailed", "Unable to disable two-factor authentication. Check the code and try again."),
);
} finally {
setMfaLoading(false);
}
},
[mfaDisableCode, t]
[mfaDisableCode, t],
);
const handleCloseMfaSetupModal = useCallback(async () => {
setMfaSetupModalOpen(false);
setMfaSetupData(null);
setMfaSetupCode('');
setMfaError('');
setMfaSetupCode("");
setMfaError("");
try {
await accountService.cancelMfaSetup();
} catch {
console.warn('Failed to clear pending MFA setup');
console.warn("Failed to clear pending MFA setup");
}
}, []);
const handleCloseMfaDisableModal = useCallback(() => {
setMfaDisableModalOpen(false);
setMfaDisableCode('');
setMfaError('');
setMfaDisableCode("");
setMfaError("");
}, []);
const handleUsernameSubmit = async (event: React.FormEvent) => {
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;
}
try {
setUsernameSubmitting(true);
setUsernameError('');
setUsernameError("");
await accountService.changeUsername(newUsername, currentPasswordForUsername);
showToast({
alertType: 'success',
title: t('changeCreds.credsUpdated', 'Account updated'),
body: t('changeCreds.description', 'Changes saved. Please log in again.'),
alertType: "success",
title: t("changeCreds.credsUpdated", "Account updated"),
body: t("changeCreds.description", "Changes saved. Please log in again."),
});
setNewUsername('');
setCurrentPasswordForUsername('');
setNewUsername("");
setCurrentPasswordForUsername("");
setUsernameModalOpen(false);
await handleLogout();
} catch (err) {
const axiosError = err as { response?: { data?: { message?: string } } };
setUsernameError(
axiosError.response?.data?.message ||
t('changeCreds.error', 'Unable to update username. Please verify your password and try again.')
t("changeCreds.error", "Unable to update username. Please verify your password and try again."),
);
} finally {
setUsernameSubmitting(false);
@@ -268,10 +274,10 @@ const AccountSection: React.FC = () => {
<Stack gap="md">
<div>
<Text fw={600} size="lg">
{t('account.accountSettings', 'Account')}
{t("account.accountSettings", "Account")}
</Text>
<Text size="sm" c="dimmed">
{t('changeCreds.header', 'Update Your Account Details')}
{t("changeCreds.header", "Update Your Account Details")}
</Text>
</div>
@@ -279,21 +285,21 @@ const AccountSection: React.FC = () => {
<Stack gap="sm">
<Text size="sm" c="dimmed">
{userIdentifier
? t('settings.general.user', 'User') + ': ' + userIdentifier
: t('account.accountSettings', 'Account Settings')}
? t("settings.general.user", "User") + ": " + userIdentifier
: t("account.accountSettings", "Account Settings")}
</Text>
<Stack gap="xs">
{isSsoUser && (
<Alert icon={<LocalIcon icon="info" width="1rem" height="1rem" />} color="blue" variant="light">
{t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.')}
{t("changeCreds.ssoManaged", "Your account is managed by your identity provider.")}
</Alert>
)}
<Group gap="sm" wrap="wrap">
{!isSsoUser && (
<Button leftSection={<LocalIcon icon="key-rounded" />} onClick={() => setPasswordModalOpen(true)}>
{t('settings.security.password.update', 'Update password')}
{t("settings.security.password.update", "Update password")}
</Button>
)}
@@ -303,12 +309,12 @@ const AccountSection: React.FC = () => {
leftSection={<LocalIcon icon="edit-rounded" />}
onClick={() => setUsernameModalOpen(true)}
>
{t('account.changeUsername', 'Change username')}
{t("account.changeUsername", "Change username")}
</Button>
)}
<Button variant="outline" color="red" leftSection={<LocalIcon icon="logout-rounded" />} onClick={handleLogout}>
{t('settings.general.logout', 'Log out')}
{t("settings.general.logout", "Log out")}
</Button>
</Group>
</Stack>
@@ -317,16 +323,13 @@ 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.'
)}
{t("account.mfa.ssoManaged", "Two-factor authentication for this account is managed by your identity provider.")}
</Alert>
) : (
<Group gap="sm" wrap="wrap">
@@ -337,7 +340,7 @@ const AccountSection: React.FC = () => {
loading={mfaLoading}
disabled={changeButtonDisabled}
>
{t('account.mfa.enableButton', 'Enable two-factor authentication')}
{t("account.mfa.enableButton", "Enable two-factor authentication")}
</Button>
) : (
<Button
@@ -345,13 +348,13 @@ const AccountSection: React.FC = () => {
color="red"
leftSection={<LocalIcon icon="close-rounded" />}
onClick={() => {
setMfaError('');
setMfaDisableCode('');
setMfaError("");
setMfaDisableCode("");
setMfaDisableModalOpen(true);
}}
disabled={changeButtonDisabled}
>
{t('account.mfa.disableButton', 'Disable two-factor authentication')}
{t("account.mfa.disableButton", "Disable two-factor authentication")}
</Button>
)}
</Group>
@@ -362,14 +365,14 @@ const AccountSection: React.FC = () => {
<Modal
opened={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
title={t('settings.security.title', 'Change password')}
title={t("settings.security.title", "Change password")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handlePasswordSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('settings.security.password.subtitle', 'Change your password. You will be logged out after updating.')}
{t("settings.security.password.subtitle", "Change your password. You will be logged out after updating.")}
</Text>
{passwordError && (
@@ -379,24 +382,24 @@ const AccountSection: React.FC = () => {
)}
<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)}
required
/>
<PasswordInput
label={t('settings.security.password.new', 'New password')}
placeholder={t('settings.security.password.newPlaceholder', 'Enter a new password')}
label={t("settings.security.password.new", "New password")}
placeholder={t("settings.security.password.newPlaceholder", "Enter a new password")}
value={newPassword}
onChange={(event) => setNewPassword(event.currentTarget.value)}
required
/>
<PasswordInput
label={t('settings.security.password.confirm', 'Confirm new password')}
placeholder={t('settings.security.password.confirmPlaceholder', 'Re-enter your new password')}
label={t("settings.security.password.confirm", "Confirm new password")}
placeholder={t("settings.security.password.confirmPlaceholder", "Re-enter your new password")}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.currentTarget.value)}
required
@@ -404,10 +407,10 @@ const AccountSection: React.FC = () => {
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPasswordModalOpen(false)}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={passwordSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('settings.security.password.update', 'Update password')}
{t("settings.security.password.update", "Update password")}
</Button>
</Group>
</Stack>
@@ -417,7 +420,7 @@ const AccountSection: React.FC = () => {
<Modal
opened={mfaSetupModalOpen}
onClose={handleCloseMfaSetupModal}
title={t('account.mfa.setupTitle', 'Set up two-factor authentication')}
title={t("account.mfa.setupTitle", "Set up two-factor authentication")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
@@ -425,22 +428,22 @@ const AccountSection: React.FC = () => {
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
'account.mfa.setupDescription',
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.'
"account.mfa.setupDescription",
"Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.",
)}
</Text>
{mfaSetupData && (
<Stack gap="sm" align="center">
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
padding: "1.5rem",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG
value={mfaSetupData.otpauthUri || ''}
value={mfaSetupData.otpauthUri || ""}
size={180}
level="H"
imageSettings={{
@@ -452,12 +455,12 @@ 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(
'account.mfa.secretWarning',
'Keep this key private. Anyone with access can generate valid authentication codes.'
"account.mfa.secretWarning",
"Keep this key private. Anyone with access can generate valid authentication codes.",
)}
</Text>
</Stack>
@@ -468,10 +471,12 @@ const AccountSection: React.FC = () => {
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t("account.mfa.codePlaceholder", "Enter 6-digit code")}
value={mfaSetupCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
@@ -481,10 +486,10 @@ const AccountSection: React.FC = () => {
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaSetupModal}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={mfaLoading}>
{t('account.mfa.confirmEnable', 'Enable')}
{t("account.mfa.confirmEnable", "Enable")}
</Button>
</Group>
</Stack>
@@ -494,14 +499,14 @@ 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">
@@ -509,10 +514,12 @@ const AccountSection: React.FC = () => {
</Alert>
)}
<TextInput
label={t('account.mfa.codeLabel', 'Authentication code')}
placeholder={t('account.mfa.codePlaceholder', 'Enter 6-digit code')}
label={t("account.mfa.codeLabel", "Authentication code")}
placeholder={t("account.mfa.codePlaceholder", "Enter 6-digit code")}
value={mfaDisableCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
@@ -522,10 +529,10 @@ const AccountSection: React.FC = () => {
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleCloseMfaDisableModal}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" color="red" loading={mfaLoading}>
{t('account.mfa.confirmDisable', 'Disable')}
{t("account.mfa.confirmDisable", "Disable")}
</Button>
</Group>
</Stack>
@@ -535,14 +542,14 @@ const AccountSection: React.FC = () => {
<Modal
opened={usernameModalOpen}
onClose={() => setUsernameModalOpen(false)}
title={t('account.changeUsername', 'Change username')}
title={t("account.changeUsername", "Change username")}
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<form onSubmit={handleUsernameSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('changeCreds.changeUsername', 'Update your username. You will be logged out after updating.')}
{t("changeCreds.changeUsername", "Update your username. You will be logged out after updating.")}
</Text>
{usernameError && (
@@ -552,27 +559,29 @@ const AccountSection: React.FC = () => {
)}
<TextInput
label={t('changeCreds.newUsername', 'New Username')}
placeholder={t('changeCreds.newUsername', 'New Username')}
label={t("changeCreds.newUsername", "New Username")}
placeholder={t("changeCreds.newUsername", "New Username")}
value={newUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewUsername(event.currentTarget.value)}
required
/>
<PasswordInput
label={t('changeCreds.oldPassword', 'Current Password')}
placeholder={t('changeCreds.oldPassword', 'Current Password')}
label={t("changeCreds.oldPassword", "Current Password")}
placeholder={t("changeCreds.oldPassword", "Current Password")}
value={currentPasswordForUsername}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCurrentPasswordForUsername(event.currentTarget.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCurrentPasswordForUsername(event.currentTarget.value)
}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setUsernameModalOpen(false)}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button type="submit" loading={usernameSubmitting} leftSection={<LocalIcon icon="save-rounded" />}>
{t('common.save', 'Save')}
{t("common.save", "Save")}
</Button>
</Group>
</Stack>
@@ -1,33 +1,33 @@
import React, { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
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 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';
import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable';
import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection';
import AuditClearDataSection from '@app/components/shared/config/configSections/audit/AuditClearDataSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { useState, useEffect } from "react";
import { isAxiosError } from "axios";
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 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";
import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable";
import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection";
import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
import LocalIcon from "@app/components/shared/LocalIcon";
const AdminAuditSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? 'NORMAL';
const hasEnterpriseLicense = licenseType === 'ENTERPRISE';
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week');
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">("week");
useEffect(() => {
const fetchSystemStatus = async () => {
@@ -40,9 +40,9 @@ const AdminAuditSection: React.FC = () => {
// Check if this is a permission/license error (403/404)
const status = isAxiosError(err) ? err.response?.status : undefined;
if (status === 403 || status === 404) {
setError('enterprise-license-required');
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);
@@ -56,7 +56,7 @@ const AdminAuditSection: React.FC = () => {
setError(null);
setSystemStatus({
enabled: true,
level: 'INFO',
level: "INFO",
retentionDays: 90,
totalEvents: 1234,
pdfMetadataEnabled: true,
@@ -73,25 +73,25 @@ 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>
);
}
if (error) {
if (error === 'enterprise-license-required') {
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.'
"audit.enterpriseRequiredMessage",
"The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.",
)}
</Alert>
);
}
return (
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
<Alert color="red" title={t("audit.error.title", "Error loading audit system")}>
{error}
</Alert>
);
@@ -99,8 +99,8 @@ 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,33 +110,30 @@ 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 && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t('audit.configureAudit', 'Configure Audit Logging')}
title={t("audit.configureAudit", "Configure Audit Logging")}
color="blue"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
'audit.configureAuditMessage',
'Adjust audit logging level, retention period, and other settings in the Security & Authentication section.'
"audit.configureAuditMessage",
"Adjust audit logging level, retention period, and other settings in the Security & Authentication section.",
)}
</Text>
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('audit.goToSettings', 'Go to Audit Settings')}
{t("audit.goToSettings", "Go to Audit Settings")}
</Button>
</Stack>
</Alert>
@@ -148,16 +145,16 @@ const AdminAuditSection: React.FC = () => {
<Tabs defaultValue="dashboard">
<Tabs.List>
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
{t('audit.tabs.dashboard', 'Dashboard')}
{t("audit.tabs.dashboard", "Dashboard")}
</Tabs.Tab>
<Tabs.Tab value="events" disabled={!isEnabled}>
{t('audit.tabs.events', 'Audit Events')}
{t("audit.tabs.events", "Audit Events")}
</Tabs.Tab>
<Tabs.Tab value="export" disabled={!isEnabled}>
{t('audit.tabs.export', 'Export')}
{t("audit.tabs.export", "Export")}
</Tabs.Tab>
<Tabs.Tab value="clearData" disabled={!isEnabled}>
{t('audit.tabs.clearData', 'Clear Data')}
{t("audit.tabs.clearData", "Clear Data")}
</Tabs.Tab>
</Tabs.List>
@@ -169,15 +166,9 @@ const AdminAuditSection: React.FC = () => {
{/* 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>
@@ -206,11 +197,8 @@ const AdminAuditSection: React.FC = () => {
</Tabs.Panel>
</Tabs>
) : (
<Alert color="blue" title={t('audit.disabled', 'Audit logging is disabled')}>
{t(
'audit.disabledMessage',
'Enable audit logging in your application configuration to track system events.'
)}
<Alert color="blue" title={t("audit.disabled", "Audit logging is disabled")}>
{t("audit.disabledMessage", "Enable audit logging in your application configuration to track system events.")}
</Alert>
)}
</Stack>
@@ -1,21 +1,21 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from '@mantine/core';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
import ProviderCard from '@app/components/shared/config/configSections/ProviderCard';
import { Provider, useAllProviders } from '@app/components/shared/config/configSections/providerDefinitions';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Stack, Text, Loader, Group, Divider, Paper, Switch, Badge, Anchor, Select, Collapse } from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { Z_INDEX_CONFIG_MODAL } from "@app/styles/zIndex";
import ProviderCard from "@app/components/shared/config/configSections/ProviderCard";
import { Provider, useAllProviders } from "@app/components/shared/config/configSections/providerDefinitions";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeedbackFlags {
noValidDocument?: boolean;
@@ -140,26 +140,26 @@ export default function AdminConnectionsSection() {
const allProviders = useAllProviders();
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
sectionName: 'connections',
sectionName: "connections",
fetchTransformer: async (): Promise<ConnectionsSettingsData & { _pending?: Record<string, unknown> }> => {
// Fetch security settings (oauth2, saml2)
const securityResponse = await apiClient.get('/api/v1/admin/settings/section/security');
const 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> } = {
@@ -170,13 +170,13 @@ export default function AdminConnectionsSection() {
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
enableMobileScanner: systemData.enableMobileScanner || false,
mobileScannerConvertToPdf: systemData.mobileScannerSettings?.convertToPdf !== false,
mobileScannerImageResolution: systemData.mobileScannerSettings?.imageResolution || 'full',
mobileScannerPageFormat: systemData.mobileScannerSettings?.pageFormat || 'A4',
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 || ''
googleDriveClientId: premiumData.proFeatures?.googleDrive?.clientId || "",
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || "",
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || "",
};
// Merge pending blocks from all endpoints
@@ -236,7 +236,7 @@ export default function AdminConnectionsSection() {
// Build delta for oauth2 settings
if (currentSettings.oauth2) {
Object.keys(currentSettings.oauth2).forEach((key) => {
if (key !== 'client') {
if (key !== "client") {
deltaSettings[`security.oauth2.${key}`] = (currentSettings.oauth2 as Record<string, unknown>)[key];
}
});
@@ -279,54 +279,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 {
sectionData: {},
deltaSettings
deltaSettings,
};
}
},
});
const {
settings,
setSettings,
loading,
fetchSettings,
isFieldPending,
} = adminSettings;
const { settings, setSettings, loading, fetchSettings, isFieldPending } = adminSettings;
useEffect(() => {
if (loginEnabled) {
@@ -347,9 +341,9 @@ export default function AdminConnectionsSection() {
await adminSettings.saveSettings();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -358,45 +352,45 @@ export default function AdminConnectionsSection() {
const actualLoading = loginEnabled ? loading : false;
const isProviderConfigured = (provider: Provider): boolean => {
if (provider.id === 'saml2') {
if (provider.id === "saml2") {
return settings?.saml2?.enabled === true;
}
if (provider.id === 'smtp') {
if (provider.id === "smtp") {
return settings?.mail?.enabled === true;
}
if (provider.id === 'telegram') {
if (provider.id === "telegram") {
return settings?.telegram?.enabled === true;
}
if (provider.id === 'googledrive') {
if (provider.id === "googledrive") {
return settings?.googleDriveEnabled === true;
}
if (provider.id === 'oauth2-generic') {
if (provider.id === "oauth2-generic") {
return settings?.oauth2?.enabled === true;
}
// Check if specific OAuth2 provider is configured (has clientId)
const providerSettings = settings?.oauth2?.client?.[provider.id];
return !!(providerSettings?.clientId);
return !!providerSettings?.clientId;
};
const getProviderSettings = (provider: Provider): ProviderSettings => {
if (provider.id === 'saml2') {
if (provider.id === "saml2") {
return settings?.saml2 || {};
}
if (provider.id === 'smtp') {
if (provider.id === "smtp") {
return settings?.mail || {};
}
if (provider.id === 'telegram') {
if (provider.id === "telegram") {
return settings?.telegram || {};
}
if (provider.id === 'googledrive') {
if (provider.id === "googledrive") {
const gd: GoogleDriveSettings = {
enabled: settings?.googleDriveEnabled,
clientId: settings?.googleDriveClientId,
@@ -406,7 +400,7 @@ export default function AdminConnectionsSection() {
return gd;
}
if (provider.id === 'oauth2-generic') {
if (provider.id === "oauth2-generic") {
const generic: OAuth2GenericSettings = {
enabled: settings?.oauth2?.enabled,
provider: settings?.oauth2?.provider,
@@ -425,7 +419,6 @@ export default function AdminConnectionsSection() {
return settings?.oauth2?.client?.[provider.id] || {};
};
if (actualLoading) {
return (
<Stack align="center" justify="center" h={200}>
@@ -434,16 +427,15 @@ export default function AdminConnectionsSection() {
);
}
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
const availableProviders = allProviders.filter((p) => !isProviderConfigured(p));
const updateProviderSettings = (provider: Provider, updatedSettings: Record<string, unknown>) => {
if (provider.id === 'smtp') {
if (provider.id === "smtp") {
setSettings({ ...settings, mail: updatedSettings as MailSettings });
} else if (provider.id === 'telegram') {
} else if (provider.id === "telegram") {
setSettings({ ...settings, telegram: updatedSettings as TelegramSettingsData });
} else if (provider.id === 'googledrive') {
} else if (provider.id === "googledrive") {
const gd = updatedSettings as GoogleDriveSettings;
setSettings({
...settings,
@@ -452,9 +444,9 @@ export default function AdminConnectionsSection() {
googleDriveApiKey: gd.apiKey,
googleDriveAppId: gd.appId,
});
} else if (provider.id === 'saml2') {
} else if (provider.id === "saml2") {
setSettings({ ...settings, saml2: updatedSettings as Saml2Settings });
} else if (provider.id === 'oauth2-generic') {
} else if (provider.id === "oauth2-generic") {
const generic = updatedSettings as OAuth2GenericSettings;
setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } });
} else {
@@ -467,8 +459,8 @@ export default function AdminConnectionsSection() {
client: {
...settings.oauth2?.client,
[provider.id]: clientSettings,
}
}
},
},
});
}
};
@@ -478,225 +470,290 @@ export default function AdminConnectionsSection() {
<Stack gap="xl" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
{/* Header */}
<div>
<Text fw={600} size="lg">
{t('admin.settings.connections.title', 'Connections')}
</Text>
<Text size="sm" c="dimmed">
{t(
'admin.settings.connections.description',
'Configure external authentication providers like OAuth2 and SAML.'
)}
</Text>
</div>
{/* Header */}
<div>
<Text fw={600} size="lg">
{t("admin.settings.connections.title", "Connections")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.connections.description", "Configure external authentication providers like OAuth2 and SAML.")}
</Text>
</div>
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.ssoAutoLogin.enable', 'Enable SSO Auto Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.ssoAutoLogin.description', 'Automatically redirect to SSO login when authentication is required')}
{/* SSO Auto Login - Premium Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t("admin.settings.connections.ssoAutoLogin.label", "SSO Auto Login")}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('ssoAutoLogin')} />
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t("admin.settings.badge.clickToUpgrade", "Click to view plan details")}
>
PRO
</Badge>
</Group>
</div>
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">{t('admin.settings.connections.mobileScanner.label', 'Mobile Phone Upload')}</Text>
</Group>
{/* Documentation Link */}
<Anchor
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
target="_blank"
size="xs"
c="blue"
>
{t('admin.settings.connections.documentation', 'View documentation')}
</Anchor>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.connections.mobileScanner.enable', 'Enable QR Code Upload')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.connections.mobileScanner.description', 'Allow users to upload files from mobile devices by scanning a QR code')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.connections.mobileScanner.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.connections.mobileScanner.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, enableMobileScanner: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableMobileScanner')} />
</Group>
</div>
{/* Mobile Scanner Settings - Only show when enabled */}
<Collapse in={settings?.enableMobileScanner || false}>
<Stack gap="md" mt="md" ml="lg" style={{ borderLeft: '2px solid var(--mantine-color-gray-3)', paddingLeft: '1rem' }}>
{/* Convert to PDF */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.connections.mobileScannerConvertToPdf', 'Convert Images to PDF')}
<Text fw={500} size="sm">
{t("admin.settings.connections.ssoAutoLogin.enable", "Enable SSO Auto Login")}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t('admin.settings.connections.mobileScannerConvertToPdfDesc', 'Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.')}
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.ssoAutoLogin.description",
"Automatically redirect to SSO login when authentication is required",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
</div>
<Group gap="xs">
<Switch
checked={settings?.ssoAutoLogin || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, ssoAutoLogin: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("ssoAutoLogin")} />
</Group>
</div>
</Stack>
</Paper>
{/* Mobile Scanner (QR Code) Upload */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group gap="xs" align="center">
<LocalIcon icon="qr-code-rounded" width="1.25rem" height="1.25rem" />
<Text fw={600} size="sm">
{t("admin.settings.connections.mobileScanner.label", "Mobile Phone Upload")}
</Text>
</Group>
{/* Documentation Link */}
<Anchor href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner" target="_blank" size="xs" c="blue">
{t("admin.settings.connections.documentation", "View documentation")}
</Anchor>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.connections.mobileScanner.enable", "Enable QR Code Upload")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.connections.mobileScanner.description",
"Allow users to upload files from mobile devices by scanning a QR code",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t("admin.settings.connections.mobileScanner.note", "Note: Requires Frontend URL to be configured. ")}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t("admin.settings.connections.mobileScanner.link", "Configure in System Settings")}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableMobileScanner || false}
onChange={(e) => {
if (!loginEnabled) return; // Block change when login disabled
setSettings({ ...settings, enableMobileScanner: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableMobileScanner")} />
</Group>
</div>
{/* Mobile Scanner Settings - Only show when enabled */}
<Collapse in={settings?.enableMobileScanner || false}>
<Stack
gap="md"
mt="md"
ml="lg"
style={{ borderLeft: "2px solid var(--mantine-color-gray-3)", paddingLeft: "1rem" }}
>
{/* Convert to PDF */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerConvertToPdf", "Convert Images to PDF")}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerConvertToPdfDesc",
"Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerConvertToPdf !== false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerConvertToPdf: e.target.checked });
}}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("mobileScannerConvertToPdf")} />
</Group>
</div>
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
{settings?.mobileScannerConvertToPdf !== false && (
<>
{/* Image Resolution */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerImageResolution", "Image Resolution")}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerImageResolutionDesc",
'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.',
)}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerImageResolution || "full"}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerImageResolution: value || "full" });
}}
data={[
{
value: "full",
label: t("admin.settings.connections.imageResolutionFull", "Full (Original Size)"),
},
{
value: "reduced",
label: t("admin.settings.connections.imageResolutionReduced", "Reduced (Max 1200px)"),
},
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge show={isFieldPending("mobileScannerImageResolution")} />
</Group>
</div>
{/* Page Format */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerPageFormat", "Page Format")}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerPageFormatDesc",
'PDF page size for converted images. "Keep" uses original image dimensions.',
)}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerPageFormat || "A4"}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerPageFormat: value || "A4" });
}}
data={[
{
value: "keep",
label: t("admin.settings.connections.pageFormatKeep", "Keep (Original Dimensions)"),
},
{ value: "A4", label: t("admin.settings.connections.pageFormatA4", "A4 (210×297mm)") },
{ value: "letter", label: t("admin.settings.connections.pageFormatLetter", "Letter (8.5×11in)") },
]}
disabled={!loginEnabled}
style={{ width: "250px" }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge show={isFieldPending("mobileScannerPageFormat")} />
</Group>
</div>
{/* Stretch to Fit */}
<div>
<Text size="sm" fw={500} mb="xs">
{t("admin.settings.connections.mobileScannerStretchToFit", "Stretch to Fit")}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t(
"admin.settings.connections.mobileScannerStretchToFitDesc",
"Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.",
)}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerStretchToFit || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked });
}}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending("mobileScannerStretchToFit")} />
</Group>
</div>
</>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
<div>
<Text fw={600} size="md" mb="md">
{t("admin.settings.connections.linkedServices", "Linked Services")}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('mobileScannerConvertToPdf')} />
</Group>
</div>
))}
</Stack>
</div>
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
{settings?.mobileScannerConvertToPdf !== false && (
<>
{/* Image Resolution */}
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.connections.mobileScannerImageResolution', 'Image Resolution')}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t('admin.settings.connections.mobileScannerImageResolutionDesc', 'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.')}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerImageResolution || 'full'}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerImageResolution: value || 'full' });
}}
data={[
{ value: 'full', label: t('admin.settings.connections.imageResolutionFull', 'Full (Original Size)') },
{ value: 'reduced', label: t('admin.settings.connections.imageResolutionReduced', 'Reduced (Max 1200px)') }
]}
disabled={!loginEnabled}
style={{ width: '250px' }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge show={isFieldPending('mobileScannerImageResolution')} />
</Group>
</div>
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Page Format */}
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.connections.mobileScannerPageFormat', 'Page Format')}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t('admin.settings.connections.mobileScannerPageFormatDesc', 'PDF page size for converted images. "Keep" uses original image dimensions.')}
</Text>
<Group gap="xs">
<Select
value={settings?.mobileScannerPageFormat || 'A4'}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerPageFormat: value || 'A4' });
}}
data={[
{ value: 'keep', label: t('admin.settings.connections.pageFormatKeep', 'Keep (Original Dimensions)') },
{ value: 'A4', label: t('admin.settings.connections.pageFormatA4', 'A4 (210×297mm)') },
{ value: 'letter', label: t('admin.settings.connections.pageFormatLetter', 'Letter (8.5×11in)') }
]}
disabled={!loginEnabled}
style={{ width: '250px' }}
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
/>
<PendingBadge show={isFieldPending('mobileScannerPageFormat')} />
</Group>
</div>
{/* Stretch to Fit */}
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.connections.mobileScannerStretchToFit', 'Stretch to Fit')}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{t('admin.settings.connections.mobileScannerStretchToFitDesc', 'Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.')}
</Text>
<Group gap="xs">
<Switch
checked={settings?.mobileScannerStretchToFit || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, mobileScannerStretchToFit: e.target.checked });
}}
disabled={!loginEnabled}
/>
<PendingBadge show={isFieldPending('mobileScannerStretchToFit')} />
</Group>
</div>
</>
)}
</Stack>
</Collapse>
</Stack>
</Paper>
{/* Linked Services Section - Only show if there are linked providers */}
{linkedProviders.length > 0 && (
<>
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.linkedServices', 'Linked Services')}
{t("admin.settings.connections.unlinkedServices", "Unlinked Services")}
</Text>
<Stack gap="sm">
{linkedProviders.map((provider) => (
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={true}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
disabled={!loginEnabled}
@@ -704,39 +761,10 @@ export default function AdminConnectionsSection() {
))}
</Stack>
</div>
)}
{/* Divider between sections */}
{availableProviders.length > 0 && <Divider />}
</>
)}
{/* Unlinked Services Section */}
{availableProviders.length > 0 && (
<div>
<Text fw={600} size="md" mb="md">
{t('admin.settings.connections.unlinkedServices', 'Unlinked Services')}
</Text>
<Stack gap="sm">
{availableProviders.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isConfigured={false}
settings={getProviderSettings(provider)}
onChange={(updatedSettings) => updateProviderSettings(provider, updatedSettings)}
disabled={!loginEnabled}
/>
))}
</Stack>
</div>
)}
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</Stack>
<SettingsStickyFooter
@@ -143,7 +143,7 @@ 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"),
@@ -191,7 +191,7 @@ export default function AdminDatabaseSection() {
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"),
@@ -211,7 +211,7 @@ export default function AdminDatabaseSection() {
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"),
@@ -272,7 +272,7 @@ export default function AdminDatabaseSection() {
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"),
@@ -291,7 +291,7 @@ export default function AdminDatabaseSection() {
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"),
@@ -322,7 +322,7 @@ 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"),
@@ -355,195 +355,196 @@ export default function AdminDatabaseSection() {
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{t("admin.settings.database.title", "Database")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.description",
"Configure custom database connection settings for enterprise deployments.",
)}
</Text>
</div>
<Badge color="grape" size="lg">
ENTERPRISE
</Badge>
</Group>
</div>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
<Text fw={600} size="lg">
{t("admin.settings.database.title", "Database")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.enableCustom.description",
"Use your own custom database configuration instead of the default embedded database",
"admin.settings.database.description",
"Configure custom database connection settings for enterprise deployments.",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
</Group>
</div>
<Badge color="grape" size="lg">
ENTERPRISE
</Badge>
</Group>
</div>
{settings?.enableCustomDatabase && (
<>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
</Group>
}
description={t(
"admin.settings.database.customUrl.description",
"Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.database.enableCustom.description",
"Use your own custom database configuration instead of the default embedded database",
)}
value={settings?.customDatabaseUrl || ""}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</Text>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
description={t(
"admin.settings.database.type.description",
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
data={[
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
{ value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
]}
<Group gap="xs">
<Switch
checked={settings?.enableCustomDatabase || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableCustomDatabase: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
</div>
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
description={t(
"admin.settings.database.hostName.description",
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
{settings?.enableCustomDatabase && (
<>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
</Group>
}
description={t(
"admin.settings.database.customUrl.description",
"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 })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.port.label", "Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.database.port.description",
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
<div>
<Select
label={
<Group gap="xs">
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
description={t(
"admin.settings.database.type.description",
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
data={[
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
{ value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
]}
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
description={t(
"admin.settings.database.name.description",
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
description={t(
"admin.settings.database.hostName.description",
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.username.label", "Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.database.username.description", "Database authentication username")}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.port.label", "Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.database.port.description",
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
disabled={!loginEnabled}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>{t("admin.settings.database.password.label", "Password")}</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
</div>
</>
)}
</Stack>
</Paper>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
description={t(
"admin.settings.database.name.description",
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.username.label", "Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.database.username.description", "Database authentication username")}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
{t("admin.settings.database.password.label", "Password")}
</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
</div>
</>
)}
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -555,262 +556,260 @@ export default function AdminDatabaseSection() {
/>
<Stack gap="lg" className="settings-section-content" style={{ marginTop: 0 }}>
<Divider my="md" />
<Divider my="md" />
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{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.")}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{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.")}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
{isEmbeddedH2
? t("admin.settings.database.embedded", "Embedded H2")
: t("admin.settings.database.external", "External DB")}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
{isEmbeddedH2
? t("admin.settings.database.embedded", "Embedded H2")
: t("admin.settings.database.external", "External DB")}
</Badge>
</Group>
</Group>
</Group>
{!isEmbeddedH2 && (
<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.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.h2Hint",
"Set the database type to H2 and disable custom database to enable backup and restore.",
)}
</Text>
</Alert>
)}
{isEmbeddedH2 && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<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>
{!isEmbeddedH2 && (
<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.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.h2Hint",
"Set the database type to H2 and disable custom database to enable backup and restore.",
)}
</Text>
</Alert>
)}
{isEmbeddedH2 && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<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>
</Group>
<Group gap="xs">
<Button
variant="light"
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" />}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.createBackup", "Create backup")}
</Button>
</Group>
</Group>
<Group gap="xs">
<Button
variant="light"
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" />}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{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")}
</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")}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
/>
<Button
variant="outline"
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
</Button>
</Group>
</Box>
<Box>
<Text fw={500} size="sm" mb={6}>
{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")}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
/>
<Button
variant="outline"
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
</Button>
</Group>
</Box>
{backupsLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? 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.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.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.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
{backupsLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? 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.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.Tr>
))}
</Table.Tbody>
</Table>
)}
</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.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
)}
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
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" />}>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.overwriteWarningBody",
"All existing data will be replaced by the uploaded backup. This action cannot be undone.",
)}
</Text>
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
</Text>
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
</Stack>
</Paper>
)}
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
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" />}>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.overwriteWarningBody",
"All existing data will be replaced by the uploaded backup. This action cannot be undone.",
)}
</Text>
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
</Text>
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
</Stack>
</Modal>
</Modal>
<Modal
opened={deleteConfirmFile !== null}
onClose={() => setDeleteConfirmFile(null)}
title={t("admin.settings.database.deleteTitle", "Delete backup")}
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" />}>
<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}>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteConfirmFile !== null}
onClose={() => setDeleteConfirmFile(null)}
title={t("admin.settings.database.deleteTitle", "Delete backup")}
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" />}>
<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}>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</div>
);
@@ -1,15 +1,15 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Paper, Text, Loader, Group, MultiSelect, Switch } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface UISettingsData {
defaultHideUnavailableTools?: boolean;
@@ -26,17 +26,10 @@ export default function AdminEndpointsSection() {
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
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,
@@ -47,7 +40,7 @@ export default function AdminEndpointsSection() {
saveSettings: saveUiSettings,
isFieldPending: isUiFieldPending,
} = useAdminSettings<UISettingsData>({
sectionName: 'ui',
sectionName: "ui",
});
useEffect(() => {
@@ -57,8 +50,16 @@ export default function AdminEndpointsSection() {
}
}, [loginEnabled, fetchSettings, fetchUiSettings]);
const { isDirty: isEndpointsDirty, resetToSnapshot: resetEndpointsSnapshot, markSaved: markEndpointsSaved } = useSettingsDirty(settings, loading);
const { isDirty: isUiDirty, resetToSnapshot: resetUiSnapshot, markSaved: markUiSaved } = useSettingsDirty(uiSettings, uiLoading);
const {
isDirty: isEndpointsDirty,
resetToSnapshot: resetEndpointsSnapshot,
markSaved: markEndpointsSaved,
} = useSettingsDirty(settings, loading);
const {
isDirty: isUiDirty,
resetToSnapshot: resetUiSnapshot,
markSaved: markUiSaved,
} = useSettingsDirty(uiSettings, uiLoading);
const isDirty = isEndpointsDirty || isUiDirty;
@@ -79,9 +80,9 @@ export default function AdminEndpointsSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -98,7 +99,7 @@ export default function AdminEndpointsSection() {
}, [isEndpointsDirty, isUiDirty, resetEndpointsSnapshot, resetUiSnapshot, setSettings, setUiSettings]);
// Override loading state when login is disabled
const actualLoading = loginEnabled ? (loading || uiLoading) : false;
const actualLoading = loginEnabled ? loading || uiLoading : false;
if (actualLoading) {
return (
@@ -110,111 +111,111 @@ export default function AdminEndpointsSection() {
// Complete list of all endpoints from frontend tool registry (alphabetical)
const commonEndpoints = [
'add-attachments',
'add-image',
'add-page-numbers',
'add-password',
'add-stamp',
'add-watermark',
'adjust-contrast',
'auto-redact',
'auto-rename',
'auto-split-pdf',
'automate',
'booklet-imposition',
'cert-sign',
'compare',
'compress-pdf',
'crop',
'dev-airgapped-docs',
'dev-api-docs',
'dev-folder-scanning-docs',
'dev-sso-guide-docs',
'edit-table-of-contents',
'eml-to-pdf',
'extract-image-scans',
'extract-images',
'file-to-pdf',
'flatten',
'get-info-on-pdf',
'handleData',
'html-to-pdf',
'img-to-pdf',
'markdown-to-pdf',
'merge-pdfs',
'multi-page-layout',
'multi-tool',
'ocr-pdf',
'overlay-pdf',
'pdf-to-csv',
'pdf-to-xlsx',
'pdf-to-epub',
'pdf-to-html',
'pdf-to-img',
'pdf-to-markdown',
'pdf-to-pdfa',
'pdf-to-presentation',
'pdf-to-single-page',
'pdf-to-text',
'pdf-to-word',
'pdf-to-xml',
'pipeline',
'rearrange-pages',
'remove-annotations',
'remove-blanks',
'remove-cert-sign',
'remove-image-pdf',
'remove-pages',
'remove-password',
'repair',
'replace-invert-pdf',
'rotate-pdf',
'sanitize-pdf',
'scale-pages',
'scanner-effect',
'show-javascript',
'sign',
'split-by-size-or-count',
'split-pages',
'split-pdf-by-chapters',
'split-pdf-by-sections',
'text-editor-pdf',
'unlock-pdf-forms',
'update-metadata',
'validate-signature',
'view-pdf',
"add-attachments",
"add-image",
"add-page-numbers",
"add-password",
"add-stamp",
"add-watermark",
"adjust-contrast",
"auto-redact",
"auto-rename",
"auto-split-pdf",
"automate",
"booklet-imposition",
"cert-sign",
"compare",
"compress-pdf",
"crop",
"dev-airgapped-docs",
"dev-api-docs",
"dev-folder-scanning-docs",
"dev-sso-guide-docs",
"edit-table-of-contents",
"eml-to-pdf",
"extract-image-scans",
"extract-images",
"file-to-pdf",
"flatten",
"get-info-on-pdf",
"handleData",
"html-to-pdf",
"img-to-pdf",
"markdown-to-pdf",
"merge-pdfs",
"multi-page-layout",
"multi-tool",
"ocr-pdf",
"overlay-pdf",
"pdf-to-csv",
"pdf-to-xlsx",
"pdf-to-epub",
"pdf-to-html",
"pdf-to-img",
"pdf-to-markdown",
"pdf-to-pdfa",
"pdf-to-presentation",
"pdf-to-single-page",
"pdf-to-text",
"pdf-to-word",
"pdf-to-xml",
"pipeline",
"rearrange-pages",
"remove-annotations",
"remove-blanks",
"remove-cert-sign",
"remove-image-pdf",
"remove-pages",
"remove-password",
"repair",
"replace-invert-pdf",
"rotate-pdf",
"sanitize-pdf",
"scale-pages",
"scanner-effect",
"show-javascript",
"sign",
"split-by-size-or-count",
"split-pages",
"split-pdf-by-chapters",
"split-pdf-by-sections",
"text-editor-pdf",
"unlock-pdf-forms",
"update-metadata",
"validate-signature",
"view-pdf",
];
// Complete list of functional and tool groups from EndpointConfiguration.java
const commonGroups = [
// Functional Groups
'PageOps',
'Convert',
'Security',
'Other',
'Advance',
'Automation',
'DeveloperTools',
'DeveloperDocs',
"PageOps",
"Convert",
"Security",
"Other",
"Advance",
"Automation",
"DeveloperTools",
"DeveloperDocs",
// Tool Groups
'CLI',
'Python',
'OpenCV',
'LibreOffice',
'Unoconvert',
'Java',
'Javascript',
'qpdf',
'Ghostscript',
'ImageMagick',
'tesseract',
'OCRmyPDF',
'Weasyprint',
'Pdftohtml',
'Calibre',
'FFmpeg',
'veraPDF',
'rar',
"CLI",
"Python",
"OpenCV",
"LibreOffice",
"Unoconvert",
"Java",
"Javascript",
"qpdf",
"Ghostscript",
"ImageMagick",
"tesseract",
"OCRmyPDF",
"Weasyprint",
"Pdftohtml",
"Calibre",
"FFmpeg",
"veraPDF",
"rar",
];
return (
@@ -222,107 +223,129 @@ export default function AdminEndpointsSection() {
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.endpoints.title', 'API Endpoints')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.endpoints.description', 'Control which API endpoints and endpoint groups are available.')}
</Text>
</div>
<div>
<Text fw={600} size="lg">
{t("admin.settings.endpoints.title", "API Endpoints")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.endpoints.description", "Control which API endpoints and endpoint groups are available.")}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.management', 'Endpoint Management')}</Text>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.toRemove.label', 'Disabled Endpoints')}</span>
<PendingBadge show={isFieldPending('toRemove')} />
</Group>
}
description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')}
value={settings.toRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.groupsToRemove.label', 'Disabled Endpoint Groups')}</span>
<PendingBadge show={isFieldPending('groupsToRemove')} />
</Group>
}
description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')}
value={settings.groupsToRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map(group => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.endpoints.userDefaults', 'User Preference Defaults')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.endpoints.userDefaultsDescription', 'Set default values for user preferences. Users can override these in their personal settings.')}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.endpoints.management", "Endpoint Management")}
</Text>
</div>
<Switch
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.defaultHideUnavailableTools.label', 'Hide unavailable tools by default')}</span>
<PendingBadge show={isUiFieldPending('defaultHideUnavailableTools')} />
</Group>
}
description={t('admin.settings.endpoints.defaultHideUnavailableTools.description', 'Remove disabled tools instead of showing them greyed out')}
checked={uiSettings.defaultHideUnavailableTools || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableTools: e.currentTarget.checked });
}}
disabled={!loginEnabled}
/>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t("admin.settings.endpoints.toRemove.label", "Disabled Endpoints")}</span>
<PendingBadge show={isFieldPending("toRemove")} />
</Group>
}
description={t("admin.settings.endpoints.toRemove.description", "Select individual endpoints to disable")}
value={settings.toRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, toRemove: value });
}}
data={commonEndpoints.map((endpoint) => ({ value: endpoint, label: endpoint }))}
searchable
clearable
placeholder="Select endpoints to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
<Switch
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.defaultHideUnavailableConversions.label', 'Hide unavailable conversions by default')}</span>
<PendingBadge show={isUiFieldPending('defaultHideUnavailableConversions')} />
</Group>
}
description={t('admin.settings.endpoints.defaultHideUnavailableConversions.description', 'Remove disabled conversion options instead of showing them greyed out')}
checked={uiSettings.defaultHideUnavailableConversions || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableConversions: e.currentTarget.checked });
}}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
<div>
<MultiSelect
label={
<Group gap="xs">
<span>{t("admin.settings.endpoints.groupsToRemove.label", "Disabled Endpoint Groups")}</span>
<PendingBadge show={isFieldPending("groupsToRemove")} />
</Group>
}
description={t("admin.settings.endpoints.groupsToRemove.description", "Select endpoint groups to disable")}
value={settings.groupsToRemove || []}
onChange={(value) => {
if (!loginEnabled) return;
setSettings({ ...settings, groupsToRemove: value });
}}
data={commonGroups.map((group) => ({ value: group, label: group }))}
searchable
clearable
placeholder="Select groups to disable"
comboboxProps={{ zIndex: 1400 }}
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.endpoints.userDefaults", "User Preference Defaults")}
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.endpoints.userDefaultsDescription",
"Set default values for user preferences. Users can override these in their personal settings.",
)}
</Text>
</div>
<Switch
label={
<Group gap="xs">
<span>
{t("admin.settings.endpoints.defaultHideUnavailableTools.label", "Hide unavailable tools by default")}
</span>
<PendingBadge show={isUiFieldPending("defaultHideUnavailableTools")} />
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableTools.description",
"Remove disabled tools instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableTools || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableTools: e.currentTarget.checked });
}}
disabled={!loginEnabled}
/>
<Switch
label={
<Group gap="xs">
<span>
{t(
"admin.settings.endpoints.defaultHideUnavailableConversions.label",
"Hide unavailable conversions by default",
)}
</span>
<PendingBadge show={isUiFieldPending("defaultHideUnavailableConversions")} />
</Group>
}
description={t(
"admin.settings.endpoints.defaultHideUnavailableConversions.description",
"Remove disabled conversion options instead of showing them greyed out",
)}
checked={uiSettings.defaultHideUnavailableConversions || false}
onChange={(e) => {
if (!loginEnabled) return;
setUiSettings({ ...uiSettings, defaultHideUnavailableConversions: e.currentTarget.checked });
}}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -334,11 +357,7 @@ export default function AdminEndpointsSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +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 { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Badge } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface FeaturesSettingsData {
serverCertificate?: {
@@ -28,52 +28,45 @@ export default function AdminFeaturesSection() {
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) {
@@ -93,9 +86,9 @@ export default function AdminFeaturesSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -119,121 +112,148 @@ export default function AdminFeaturesSection() {
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.features.title', 'Features')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.features.description', 'Configure optional features and functionality.')}
</Text>
</div>
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings/adminPlan')}
title={t('admin.settings.badge.clickToUpgrade', 'Click to view plan details')}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.features.serverCertificate.description', 'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality')}
<div>
<Text fw={600} size="lg">
{t("admin.settings.features.title", "Features")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.features.description", "Configure optional features and functionality.")}
</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled.label', 'Enable Server Certificate')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
{/* Server Certificate - Pro Feature */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{t("admin.settings.features.serverCertificate.label", "Server Certificate")}
</Text>
<Badge
color="grape"
size="sm"
style={{ cursor: "pointer" }}
onClick={() => navigate("/settings/adminPlan")}
title={t("admin.settings.badge.clickToUpgrade", "Click to view plan details")}
>
PRO
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.features.serverCertificate.description",
'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality',
)}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.features.serverCertificate.enabled.label", "Enable Server Certificate")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.enabled.description",
'Enable server-side certificate for "Sign with Stirling-PDF" option',
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked },
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("serverCertificate.enabled")} />
</Group>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.enabled ?? true}
onChange={(e) => {
if (!loginEnabled) return;
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.features.serverCertificate.organizationName.label", "Organization Name")}</span>
<PendingBadge show={isFieldPending("serverCertificate.organizationName")} />
</Group>
}
description={t(
"admin.settings.features.serverCertificate.organizationName.description",
"Organization name for generated certificates",
)}
value={settings.serverCertificate?.organizationName || "Stirling-PDF"}
onChange={(e) =>
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked }
});
}}
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value },
})
}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.enabled')} />
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.organizationName.label', 'Organization Name')}</span>
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.organizationName.description', 'Organization name for generated certificates')}
value={settings.serverCertificate?.organizationName || 'Stirling-PDF'}
onChange={(e) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value }
})}
placeholder="Stirling-PDF"
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.validity.label', 'Certificate Validity (days)')}</span>
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
</Group>
}
description={t('admin.settings.features.serverCertificate.validity.description', 'Number of days the certificate will be valid')}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) => setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, validity: Number(value) }
})}
min={1}
max={3650}
disabled={!loginEnabled}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup.label', 'Regenerate on Startup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => {
if (!loginEnabled) return;
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.features.serverCertificate.validity.label", "Certificate Validity (days)")}</span>
<PendingBadge show={isFieldPending("serverCertificate.validity")} />
</Group>
}
description={t(
"admin.settings.features.serverCertificate.validity.description",
"Number of days the certificate will be valid",
)}
value={settings.serverCertificate?.validity ?? 365}
onChange={(value) =>
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked }
});
}}
serverCertificate: { ...settings.serverCertificate, validity: Number(value) },
})
}
min={1}
max={3650}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('serverCertificate.regenerateOnStartup')} />
</Group>
</div>
</Stack>
</Paper>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.features.serverCertificate.regenerateOnStartup.label", "Regenerate on Startup")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.features.serverCertificate.regenerateOnStartup.description",
"Generate new certificate on each application startup",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.serverCertificate?.regenerateOnStartup ?? false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({
...settings,
serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked },
});
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("serverCertificate.regenerateOnStartup")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -245,11 +265,7 @@ export default function AdminFeaturesSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,16 +1,16 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TextInput, Stack, Paper, Text, Loader, Group, Alert } from '@mantine/core';
import WarningIcon from '@mui/icons-material/Warning';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TextInput, Stack, Paper, Text, Loader, Group, Alert } from "@mantine/core";
import WarningIcon from "@mui/icons-material/Warning";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface LegalSettingsData {
termsAndConditions?: string;
@@ -25,17 +25,10 @@ export default function AdminLegalSection() {
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
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) {
@@ -43,7 +36,6 @@ export default function AdminLegalSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -55,9 +47,9 @@ export default function AdminLegalSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -80,112 +72,123 @@ export default function AdminLegalSection() {
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.legal.title', 'Legal Documents')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.legal.description', 'Configure links to legal documents and policies.')}
</Text>
</div>
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.legal.title", "Legal Documents")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.legal.description", "Configure links to legal documents and policies.")}
</Text>
</div>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t('admin.settings.legal.disclaimer.title', 'Legal Responsibility Warning')}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
'admin.settings.legal.disclaimer.message',
'By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.'
)}
</Text>
</Alert>
{/* Legal Disclaimer */}
<Alert
icon={<WarningIcon style={{ fontSize: 18 }} />}
title={t("admin.settings.legal.disclaimer.title", "Legal Responsibility Warning")}
color="yellow"
variant="light"
>
<Text size="sm">
{t(
"admin.settings.legal.disclaimer.message",
"By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.",
)}
</Text>
</Alert>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.termsAndConditions.label', 'Terms and Conditions')}</span>
<PendingBadge show={isFieldPending('termsAndConditions')} />
</Group>
}
description={t('admin.settings.legal.termsAndConditions.description', 'URL or filename to terms and conditions')}
value={settings.termsAndConditions || ''}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.termsAndConditions.label", "Terms and Conditions")}</span>
<PendingBadge show={isFieldPending("termsAndConditions")} />
</Group>
}
description={t(
"admin.settings.legal.termsAndConditions.description",
"URL or filename to terms and conditions",
)}
value={settings.termsAndConditions || ""}
onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })}
placeholder="https://example.com/terms"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.privacyPolicy.label', 'Privacy Policy')}</span>
<PendingBadge show={isFieldPending('privacyPolicy')} />
</Group>
}
description={t('admin.settings.legal.privacyPolicy.description', 'URL or filename to privacy policy')}
value={settings.privacyPolicy || ''}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.privacyPolicy.label", "Privacy Policy")}</span>
<PendingBadge show={isFieldPending("privacyPolicy")} />
</Group>
}
description={t("admin.settings.legal.privacyPolicy.description", "URL or filename to privacy policy")}
value={settings.privacyPolicy || ""}
onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })}
placeholder="https://example.com/privacy"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.accessibilityStatement.label', 'Accessibility Statement')}</span>
<PendingBadge show={isFieldPending('accessibilityStatement')} />
</Group>
}
description={t('admin.settings.legal.accessibilityStatement.description', 'URL or filename to accessibility statement')}
value={settings.accessibilityStatement || ''}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.accessibilityStatement.label", "Accessibility Statement")}</span>
<PendingBadge show={isFieldPending("accessibilityStatement")} />
</Group>
}
description={t(
"admin.settings.legal.accessibilityStatement.description",
"URL or filename to accessibility statement",
)}
value={settings.accessibilityStatement || ""}
onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })}
placeholder="https://example.com/accessibility"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.cookiePolicy.label', 'Cookie Policy')}</span>
<PendingBadge show={isFieldPending('cookiePolicy')} />
</Group>
}
description={t('admin.settings.legal.cookiePolicy.description', 'URL or filename to cookie policy')}
value={settings.cookiePolicy || ''}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.cookiePolicy.label", "Cookie Policy")}</span>
<PendingBadge show={isFieldPending("cookiePolicy")} />
</Group>
}
description={t("admin.settings.legal.cookiePolicy.description", "URL or filename to cookie policy")}
value={settings.cookiePolicy || ""}
onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })}
placeholder="https://example.com/cookies"
disabled={!loginEnabled}
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.impressum.label', 'Impressum')}</span>
<PendingBadge show={isFieldPending('impressum')} />
</Group>
}
description={t('admin.settings.legal.impressum.description', 'URL or filename to impressum (required in some jurisdictions)')}
value={settings.impressum || ''}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.legal.impressum.label", "Impressum")}</span>
<PendingBadge show={isFieldPending("impressum")} />
</Group>
}
description={t(
"admin.settings.legal.impressum.description",
"URL or filename to impressum (required in some jurisdictions)",
)}
value={settings.impressum || ""}
onChange={(e) => setSettings({ ...settings, impressum: e.target.value })}
placeholder="https://example.com/impressum"
disabled={!loginEnabled}
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -197,11 +200,7 @@ export default function AdminLegalSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +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 { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import EditableSecretField from '@app/components/shared/EditableSecretField';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { TextInput, NumberInput, Switch, Stack, Paper, Text, Loader, Group, Anchor } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface MailSettingsData {
enabled?: boolean;
@@ -36,27 +36,20 @@ export default function AdminMailSection() {
const { loginEnabled } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailSettingsData>({
sectionName: 'mail',
fetchTransformer: async (): Promise<MailSettingsData & { _pending?: Partial<MailSettingsData> }> => {
const mailResponse = await apiClient.get<MailApiResponse>('/api/v1/admin/settings/section/mail');
return mailResponse.data || {};
},
saveTransformer: (settings: MailSettingsData) => {
return {
sectionData: settings,
deltaSettings: {}
};
}
});
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();
@@ -71,9 +64,9 @@ export default function AdminMailSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -95,127 +88,149 @@ export default function AdminMailSection() {
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">{t('admin.settings.mail.title', 'Mail Configuration')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.mail.description', 'Configure SMTP settings for email notifications.')}
</Text>
</div>
<Text fw={600} size="lg">
{t("admin.settings.mail.title", "Mail Configuration")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.mail.description", "Configure SMTP settings for email notifications.")}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mail.enabled.label", "Enable Mail")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("admin.settings.mail.enabled.description", "Enable email notifications and SMTP functionality")}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</Group>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">
{t("admin.settings.mail.enableInvites.label", "Enable Email Invites")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.mail.enableInvites.description",
"Allow admins to invite users via email with auto-generated passwords",
)}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t("admin.settings.mail.frontendUrlNote.note", "Note: Requires Frontend URL to be configured. ")}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t("admin.settings.mail.frontendUrlNote.link", "Configure in System Settings")}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) => setSettings({ ...settings, enableInvites: e.target.checked })}
disabled={!settings.enabled}
/>
<PendingBadge show={isFieldPending("enableInvites")} />
</Group>
</Group>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled.label', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.host.label", "SMTP Host")}</span>
<PendingBadge show={isFieldPending("host")} />
</Group>
}
description={t("admin.settings.mail.host.description", "SMTP server hostname")}
value={settings.host || ""}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
placeholder="smtp.example.com"
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</Group>
</div>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites.label', 'Enable Email Invites')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
<Text size="xs" c="orange" mt={8} fw={500}>
{t('admin.settings.mail.frontendUrlNote.note', 'Note: Requires Frontend URL to be configured. ')}
<Anchor href="#" onClick={(e) => { e.preventDefault(); navigate('/settings/adminGeneral#frontendUrl'); }} c="orange" td="underline">
{t('admin.settings.mail.frontendUrlNote.link', 'Configure in System Settings')}
</Anchor>
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enableInvites || false}
onChange={(e) => setSettings({ ...settings, enableInvites: e.target.checked })}
disabled={!settings.enabled}
<NumberInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.port.label", "SMTP Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t(
"admin.settings.mail.port.description",
"SMTP server port (typically 587 for TLS, 465 for SSL)",
)}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
<PendingBadge show={isFieldPending('enableInvites')} />
</Group>
</Group>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.host.label', 'SMTP Host')}</span>
<PendingBadge show={isFieldPending('host')} />
</Group>
}
description={t('admin.settings.mail.host.description', 'SMTP server hostname')}
value={settings.host || ''}
onChange={(e) => setSettings({ ...settings, host: e.target.value })}
placeholder="smtp.example.com"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.username.label", "SMTP Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t("admin.settings.mail.username.description", "SMTP authentication username")}
value={settings.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
/>
</div>
<div>
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.port.label', 'SMTP Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
description={t('admin.settings.mail.port.description', 'SMTP server port (typically 587 for TLS, 465 for SSL)')}
value={settings.port || 587}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
max={65535}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
{t("admin.settings.mail.password.label", "SMTP Password")}
</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.mail.password.description", "SMTP authentication password")}
value={settings.password || ""}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter SMTP password"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.username.label', 'SMTP Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
description={t('admin.settings.mail.username.description', 'SMTP authentication username')}
value={settings.username || ''}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
/>
</div>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
<EditableSecretField
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
value={settings.password || ''}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter SMTP password"
/>
</div>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.from.label', 'From Address')}</span>
<PendingBadge show={isFieldPending('from')} />
</Group>
}
description={t('admin.settings.mail.from.description', 'Email address to use as sender')}
value={settings.from || ''}
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
placeholder="[email protected]"
/>
</div>
</Stack>
</Paper>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.mail.from.label", "From Address")}</span>
<PendingBadge show={isFieldPending("from")} />
</Group>
}
description={t("admin.settings.mail.from.description", "Email address to use as sender")}
value={settings.from || ""}
onChange={(e) => setSettings({ ...settings, from: e.target.value })}
placeholder="[email protected]"
/>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -227,11 +242,7 @@ export default function AdminMailSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,20 +1,20 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { alert } from '@app/components/toast';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Divider, Loader, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePlans } from "@app/hooks/usePlans";
import licenseService, { PlanTierGroup, mapLicenseToTier } from "@app/services/licenseService";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { useLicense } from "@app/contexts/LicenseContext";
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { alert } from "@app/components/toast";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { getPreferredCurrency, setCachedCurrency } from "@app/utils/currencyDetection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@core/components/shared/config/LoginRequiredBanner";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
@@ -39,13 +39,13 @@ const AdminPlanSection: React.FC = () => {
}, [error]);
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
{ value: "gbp", label: "British pound (GBP, £)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
];
const handleManageClick = useCallback(async () => {
@@ -56,28 +56,25 @@ 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.');
if (!licenseInfo?.licenseType || licenseInfo.licenseType === "NORMAL") {
throw new Error("No valid license found. Please purchase a license before accessing the billing portal.");
}
if (!licenseInfo?.licenseKey) {
throw new Error('License key missing. Please contact support.');
throw new Error("License key missing. Please contact support.");
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey
);
const response = await licenseService.createBillingPortalSession(window.location.href, licenseInfo.licenseKey);
// Open billing portal in new tab
window.open(response.url, '_blank');
window.open(response.url, "_blank");
} catch (error: unknown) {
console.error('Failed to open billing portal:', error);
console.error("Failed to open billing portal:", error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: (error instanceof Error ? error.message : undefined) || 'Please try again or contact support.',
alertType: "error",
title: t("billing.portal.error", "Failed to open billing portal"),
body: (error instanceof Error ? error.message : undefined) || "Please try again or contact support.",
});
}
}, [licenseInfo, t, validateLoginEnabled]);
@@ -96,19 +93,19 @@ const AdminPlanSection: React.FC = () => {
}
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === 'free') {
if (planGroup.tier === "free") {
return;
}
// Prevent free tier users from directly accessing enterprise (must have server first)
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'free' && planGroup.tier === 'enterprise') {
if (currentTier === "free" && planGroup.tier === "enterprise") {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
@@ -124,13 +121,13 @@ const AdminPlanSection: React.FC = () => {
},
});
},
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled]
[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}}', {
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
limit: licenseAlert.freeTierLimit,
});
}
@@ -138,9 +135,9 @@ const AdminPlanSection: React.FC = () => {
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById('available-plans-section');
const el = document.getElementById("available-plans-section");
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
@@ -152,7 +149,7 @@ 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>
);
@@ -172,19 +169,19 @@ const AdminPlanSection: React.FC = () => {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t('plan.licenseWarning.title', 'Free self-hosted limit reached')}
message={t('plan.licenseWarning.body', {
title={t("plan.licenseWarning.title", "Free self-hosted limit reached")}
message={t("plan.licenseWarning.body", {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
})}
buttonText={t('plan.licenseWarning.cta', 'See plans')}
buttonText={t("plan.licenseWarning.cta", "See plans")}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
@@ -1,16 +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 { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TextInput, Switch, Stack, Paper, Text, Loader, Group, Alert, List } from "@mantine/core";
import { alert } from "@app/components/toast";
import LocalIcon from "@app/components/shared/LocalIcon";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
interface PremiumSettingsData {
key?: string;
@@ -22,17 +22,10 @@ export default function AdminPremiumSection() {
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) {
@@ -40,7 +33,6 @@ export default function AdminPremiumSection() {
}
}, [loginEnabled]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading);
const handleSave = async () => {
if (!validateLoginEnabled()) {
@@ -52,9 +44,9 @@ export default function AdminPremiumSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -77,75 +69,100 @@ export default function AdminPremiumSection() {
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.premium.title', 'Premium & Enterprise')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.premium.description', 'Configure your premium or enterprise license key.')}
</Text>
</div>
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t("admin.settings.premium.title", "Premium & Enterprise")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.premium.description", "Configure your premium or enterprise license key.")}
</Text>
</div>
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t('admin.settings.premium.movedFeatures.title', 'Premium Features Distributed')}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.movedFeatures.message', 'Premium and Enterprise features are now organized in their respective sections:')}
</Text>
<List mt="xs" size="sm">
<List.Item><Text size="sm" component="span"><strong>SSO Auto Login</strong> (PRO) - Connections</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Custom Metadata</strong> (PRO) - General</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Audit Logging</strong> (ENTERPRISE) - Security</Text></List.Item>
<List.Item><Text size="sm" component="span"><strong>Database Configuration</strong> (ENTERPRISE) - Database</Text></List.Item>
</List>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.premium.license', 'License Configuration')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key')}
value={settings.key || ''}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled.label', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
{/* Notice about moved features */}
<Alert
variant="light"
color="blue"
title={t("admin.settings.premium.movedFeatures.title", "Premium Features Distributed")}
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t(
"admin.settings.premium.movedFeatures.message",
"Premium and Enterprise features are now organized in their respective sections:",
)}
</Text>
<List mt="xs" size="sm">
<List.Item>
<Text size="sm" component="span">
<strong>SSO Auto Login</strong> (PRO) - Connections
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Custom Metadata</strong> (PRO) - General
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Audit Logging</strong> (ENTERPRISE) - Security
</Text>
</List.Item>
<List.Item>
<Text size="sm" component="span">
<strong>Database Configuration</strong> (ENTERPRISE) - Database
</Text>
</List.Item>
</List>
</Alert>
{/* License Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.premium.license", "License Configuration")}
</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t("admin.settings.premium.key.label", "License Key")}</span>
<PendingBadge show={isFieldPending("key")} />
</Group>
}
description={t("admin.settings.premium.key.description", "Enter your premium or enterprise license key")}
value={settings.key || ""}
onChange={(e) => setSettings({ ...settings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enabled')} />
</Group>
</div>
</Stack>
</Paper>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.premium.enabled.label", "Enable Premium Features")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("admin.settings.premium.enabled.description", "Enable license key checks for pro/enterprise features")}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings.enabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enabled")} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -157,11 +174,7 @@ export default function AdminPremiumSection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,16 +1,16 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Switch, Stack, Paper, Text, Loader, Group } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Switch, Stack, Paper, Text, Loader, Group } from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import apiClient from "@app/services/apiClient";
interface PrivacySettingsData {
enableAnalytics?: boolean;
@@ -23,62 +23,55 @@ export default function AdminPrivacySection() {
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) {
@@ -103,9 +96,9 @@ export default function AdminPrivacySection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -124,92 +117,109 @@ export default function AdminPrivacySection() {
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<LoginRequiredBanner show={!loginEnabled} />
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">{t('admin.settings.privacy.title', 'Privacy')}</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.privacy.description', 'Configure privacy and data collection settings.')}
</Text>
</div>
<div>
<Text fw={600} size="lg">
{t("admin.settings.privacy.title", "Privacy")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.privacy.description", "Configure privacy and data collection settings.")}
</Text>
</div>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.analytics', 'Analytics & Tracking')}</Text>
{/* Analytics & Tracking */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.privacy.analytics", "Analytics & Tracking")}
</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics.label', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.enableAnalytics.label", "Enable Analytics")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.enableAnalytics.description",
"Collect anonymous usage analytics to help improve the application",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("enableAnalytics")} />
</Group>
</div>
<Group gap="xs">
<Switch
checked={settings?.enableAnalytics || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, enableAnalytics: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableAnalytics')} />
</Group>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled.label', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.metricsEnabled.label", "Enable Metrics")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"admin.settings.privacy.metricsEnabled.description",
"Enable collection of performance and usage metrics",
)}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("metricsEnabled")} />
</Group>
</div>
<Group gap="xs">
<Switch
checked={settings?.metricsEnabled || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, metricsEnabled: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('metricsEnabled')} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
</Paper>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.privacy.searchEngine', 'Search Engine Visibility')}</Text>
{/* Search Engine Visibility */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.privacy.searchEngine", "Search Engine Visibility")}
</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility.label', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">
{t("admin.settings.privacy.googleVisibility.label", "Google Visibility")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t("admin.settings.privacy.googleVisibility.description", "Allow search engines to index this application")}
</Text>
</div>
<Group gap="xs">
<Switch
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending("googleVisibility")} />
</Group>
</div>
<Group gap="xs">
<Switch
checked={settings?.googleVisibility || false}
onChange={(e) => {
if (!loginEnabled) return;
setSettings({ ...settings, googleVisibility: e.target.checked });
}}
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('googleVisibility')} />
</Group>
</div>
</Stack>
</Paper>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
@@ -221,11 +231,7 @@ export default function AdminPrivacySection() {
/>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</div>
);
}
@@ -1,17 +1,17 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Anchor, Badge, Group, Loader, Paper, Stack, Switch, Text } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import apiClient from '@app/services/apiClient';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter';
import { useSettingsDirty } from '@app/hooks/useSettingsDirty';
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Anchor, Badge, Group, Loader, Paper, Stack, Switch, Text } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import apiClient from "@app/services/apiClient";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
interface StorageSharingSettingsData {
enabled?: boolean;
@@ -37,47 +37,40 @@ export default function AdminStorageSharingSection() {
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<StorageSharingSettingsData>({
sectionName: 'storage',
fetchTransformer: async () => {
const [storageResponse, systemResponse, mailResponse] = await Promise.all([
apiClient.get('/api/v1/admin/settings/section/storage'),
apiClient.get('/api/v1/admin/settings/section/system'),
apiClient.get('/api/v1/admin/settings/section/mail'),
]);
const { 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) {
@@ -106,9 +99,9 @@ export default function AdminStorageSharingSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
@@ -123,190 +116,203 @@ export default function AdminStorageSharingSection() {
return (
<div className="settings-section-container">
<div className="settings-section-content">
<Stack gap="sm">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group gap="xs" align="center">
<Text fw={600} size="lg">{t('admin.settings.storage.title', 'File Storage & Sharing')}</Text>
<Badge size="sm" variant="light" color="orange">{t('toolPanel.alpha', 'Alpha')}</Badge>
</Group>
<Text size="sm" c="dimmed">
{t('admin.settings.storage.description', 'Control server storage and sharing options.')}
</Text>
<div className="settings-section-content">
<Stack gap="sm">
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Group gap="xs" align="center">
<Text fw={600} size="lg">
{t("admin.settings.storage.title", "File Storage & Sharing")}
</Text>
<Badge size="sm" variant="light" color="orange">
{t("toolPanel.alpha", "Alpha")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t("admin.settings.storage.description", "Control server storage and sharing options.")}
</Text>
</div>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.enabled.label", "Enable Server File Storage")}
</Text>
{isFieldPending("enabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t("admin.settings.storage.enabled.description", "Allow users to store files on the server.")}
</Text>
</div>
<Switch
checked={storageEnabled}
onChange={(e) => setSettings({ ...settings, enabled: e.currentTarget.checked })}
disabled={!loginEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.enabled.label", "Enable Sharing")}
</Text>
{isFieldPending("sharing.enabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t("admin.settings.storage.sharing.enabled.description", "Allow users to share stored files.")}
</Text>
</div>
<Switch
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.links.label", "Enable Share Links")}
</Text>
{isFieldPending("sharing.linkEnabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t("admin.settings.storage.sharing.links.description", "Allow sharing via signed-in links.")}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
{t("admin.settings.storage.sharing.links.frontendUrlNote", "Requires a Frontend URL. ")}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminGeneral#frontendUrl");
}}
c="orange"
td="underline"
>
{t("admin.settings.storage.sharing.links.frontendUrlLink", "Configure in System Settings")}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, linkEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !frontendUrlConfigured}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.sharing.email.label", "Enable Email Sharing")}
</Text>
{isFieldPending("sharing.emailEnabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t("admin.settings.storage.sharing.email.description", "Allow sharing with email addresses.")}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
{t("admin.settings.storage.sharing.email.mailNote", "Requires mail configuration. ")}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate("/settings/adminConnections");
}}
c="orange"
td="underline"
>
{t("admin.settings.storage.sharing.email.mailLink", "Configure Mail Settings")}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, emailEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">
{t("admin.settings.storage.signing.enabled.label", "Enable Group Signing")}
</Text>
{isFieldPending("signing.enabled") && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t(
"admin.settings.storage.signing.enabled.description",
"Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.",
)}
</Text>
</div>
<Switch
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
signing: { ...settings.signing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
</Stack>
</div>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.enabled.label', 'Enable Server File Storage')}</Text>
{isFieldPending('enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.enabled.description', 'Allow users to store files on the server.')}
</Text>
</div>
<Switch
checked={storageEnabled}
onChange={(e) => setSettings({ ...settings, enabled: e.currentTarget.checked })}
disabled={!loginEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.enabled.label', 'Enable Sharing')}</Text>
{isFieldPending('sharing.enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.enabled.description', 'Allow users to share stored files.')}
</Text>
</div>
<Switch
checked={settings.sharing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.links.label', 'Enable Share Links')}</Text>
{isFieldPending('sharing.linkEnabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.links.description', 'Allow sharing via signed-in links.')}
</Text>
{!frontendUrlConfigured && (
<Text size="xs" c="orange">
{t('admin.settings.storage.sharing.links.frontendUrlNote', 'Requires a Frontend URL. ')}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/settings/adminGeneral#frontendUrl');
}}
c="orange"
td="underline"
>
{t('admin.settings.storage.sharing.links.frontendUrlLink', 'Configure in System Settings')}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.linkEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, linkEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !frontendUrlConfigured}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.sharing.email.label', 'Enable Email Sharing')}</Text>
{isFieldPending('sharing.emailEnabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.sharing.email.description', 'Allow sharing with email addresses.')}
</Text>
{!mailEnabled && (
<Text size="xs" c="orange">
{t('admin.settings.storage.sharing.email.mailNote', 'Requires mail configuration. ')}
<Anchor
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/settings/adminConnections');
}}
c="orange"
td="underline"
>
{t('admin.settings.storage.sharing.email.mailLink', 'Configure Mail Settings')}
</Anchor>
</Text>
)}
</div>
<Switch
checked={settings.sharing?.emailEnabled ?? false}
onChange={(e) =>
setSettings({
...settings,
sharing: { ...settings.sharing, emailEnabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Group gap="xs" align="center">
<Text fw={600} size="sm">{t('admin.settings.storage.signing.enabled.label', 'Enable Group Signing')}</Text>
{isFieldPending('signing.enabled') && <PendingBadge show={true} />}
</Group>
<Text size="xs" c="dimmed">
{t('admin.settings.storage.signing.enabled.description', 'Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.')}
</Text>
</div>
<Switch
checked={settings.signing?.enabled ?? false}
onChange={(e) =>
setSettings({
...settings,
signing: { ...settings.signing, enabled: e.currentTarget.checked },
})
}
disabled={!loginEnabled || !storageEnabled}
styles={getDisabledStyles()}
style={{ flexShrink: 0 }}
/>
</Group>
</Paper>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Stack>
</div>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</div>
);
}
@@ -1,59 +1,59 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService';
import UsageAnalyticsChart from '@app/components/shared/config/configSections/usage/UsageAnalyticsChart';
import UsageAnalyticsTable from '@app/components/shared/config/configSections/usage/UsageAnalyticsTable';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import EnterpriseRequiredBanner from '@app/components/shared/config/EnterpriseRequiredBanner';
import React, { useState, useEffect, useCallback } from "react";
import { Stack, Group, Text, Button, SegmentedControl, Loader, Alert, Card } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import usageAnalyticsService, { EndpointStatisticsResponse } from "@app/services/usageAnalyticsService";
import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart";
import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
const AdminUsageSection: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { loginEnabled } = useLoginRequired();
const { config } = useAppConfig();
const licenseType = config?.license ?? 'NORMAL';
const hasEnterpriseLicense = licenseType === 'ENTERPRISE';
const licenseType = config?.license ?? "NORMAL";
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10');
const [dataType, setDataType] = useState<'all' | 'api' | 'ui'>('api');
const [displayMode, setDisplayMode] = useState<"top10" | "top20" | "all">("top10");
const [dataType, setDataType] = useState<"all" | "api" | "ui">("api");
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
const totalVisits = 15847;
const allEndpoints = [
{ endpoint: 'merge-pdfs', visits: 3245, percentage: (3245 / totalVisits) * 100 },
{ endpoint: 'compress-pdf', visits: 2891, percentage: (2891 / totalVisits) * 100 },
{ endpoint: 'pdf-to-img', visits: 2156, percentage: (2156 / totalVisits) * 100 },
{ endpoint: 'split-pdf', visits: 1834, percentage: (1834 / totalVisits) * 100 },
{ endpoint: 'rotate-pdf', visits: 1523, percentage: (1523 / totalVisits) * 100 },
{ endpoint: 'ocr-pdf', visits: 1287, percentage: (1287 / totalVisits) * 100 },
{ endpoint: 'add-watermark', visits: 945, percentage: (945 / totalVisits) * 100 },
{ endpoint: 'extract-images', visits: 782, percentage: (782 / totalVisits) * 100 },
{ endpoint: 'add-password', visits: 621, percentage: (621 / totalVisits) * 100 },
{ endpoint: 'html-to-pdf', visits: 563, percentage: (563 / totalVisits) * 100 },
{ endpoint: 'remove-password', visits: 487, percentage: (487 / totalVisits) * 100 },
{ endpoint: 'pdf-to-pdfa', visits: 423, percentage: (423 / totalVisits) * 100 },
{ endpoint: 'extract-pdf-metadata', visits: 356, percentage: (356 / totalVisits) * 100 },
{ endpoint: 'add-page-numbers', visits: 298, percentage: (298 / totalVisits) * 100 },
{ endpoint: 'crop', visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: 'flatten', visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: 'sanitize-pdf', visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: 'auto-split-pdf', visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: 'scale-pages', visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: 'compare-pdfs', visits: 42, percentage: (42 / totalVisits) * 100 },
{ endpoint: "merge-pdfs", visits: 3245, percentage: (3245 / totalVisits) * 100 },
{ endpoint: "compress-pdf", visits: 2891, percentage: (2891 / totalVisits) * 100 },
{ endpoint: "pdf-to-img", visits: 2156, percentage: (2156 / totalVisits) * 100 },
{ endpoint: "split-pdf", visits: 1834, percentage: (1834 / totalVisits) * 100 },
{ endpoint: "rotate-pdf", visits: 1523, percentage: (1523 / totalVisits) * 100 },
{ endpoint: "ocr-pdf", visits: 1287, percentage: (1287 / totalVisits) * 100 },
{ endpoint: "add-watermark", visits: 945, percentage: (945 / totalVisits) * 100 },
{ endpoint: "extract-images", visits: 782, percentage: (782 / totalVisits) * 100 },
{ endpoint: "add-password", visits: 621, percentage: (621 / totalVisits) * 100 },
{ endpoint: "html-to-pdf", visits: 563, percentage: (563 / totalVisits) * 100 },
{ endpoint: "remove-password", visits: 487, percentage: (487 / totalVisits) * 100 },
{ endpoint: "pdf-to-pdfa", visits: 423, percentage: (423 / totalVisits) * 100 },
{ endpoint: "extract-pdf-metadata", visits: 356, percentage: (356 / totalVisits) * 100 },
{ endpoint: "add-page-numbers", visits: 298, percentage: (298 / totalVisits) * 100 },
{ endpoint: "crop", visits: 245, percentage: (245 / totalVisits) * 100 },
{ endpoint: "flatten", visits: 187, percentage: (187 / totalVisits) * 100 },
{ endpoint: "sanitize-pdf", visits: 134, percentage: (134 / totalVisits) * 100 },
{ endpoint: "auto-split-pdf", visits: 98, percentage: (98 / totalVisits) * 100 },
{ endpoint: "scale-pages", visits: 76, percentage: (76 / totalVisits) * 100 },
{ endpoint: "compare-pdfs", visits: 42, percentage: (42 / totalVisits) * 100 },
];
let filteredEndpoints = allEndpoints;
if (displayMode === 'top10') {
if (displayMode === "top10") {
filteredEndpoints = allEndpoints.slice(0, 10);
} else if (displayMode === 'top20') {
} else if (displayMode === "top20") {
filteredEndpoints = allEndpoints.slice(0, 20);
}
@@ -69,12 +69,12 @@ const AdminUsageSection: React.FC = () => {
setLoading(true);
setError(null);
const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20;
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);
}
@@ -103,14 +103,14 @@ const AdminUsageSection: React.FC = () => {
const getDisplayModeLabel = () => {
switch (displayMode) {
case 'top10':
return t('usage.showing.top10', 'Top 10');
case 'top20':
return t('usage.showing.top20', 'Top 20');
case 'all':
return t('usage.showing.all', 'All');
case "top10":
return t("usage.showing.top10", "Top 10");
case "top20":
return t("usage.showing.top20", "Top 20");
case "all":
return t("usage.showing.all", "All");
default:
return '';
return "";
}
};
@@ -120,7 +120,7 @@ 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 +128,7 @@ 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>
);
@@ -136,14 +136,14 @@ 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.')}
<Alert color="yellow" title={t("usage.noData", "No data available")}>
{t("usage.noDataMessage", "No usage statistics are currently available.")}
</Alert>
);
}
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
endpoint: endpoint.endpoint ?? t('usage.table.unknownEndpoint', 'Unknown endpoint'),
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,
}));
@@ -154,56 +154,50 @@ 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 */}
{loginEnabled && hasEnterpriseLicense && (
<Alert
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
title={t('usage.aboutUsageAnalytics', 'About Usage Analytics')}
title={t("usage.aboutUsageAnalytics", "About Usage Analytics")}
color="cyan"
variant="light"
>
<Stack gap="xs">
<Text size="sm">
{t(
'usage.usageAnalyticsExplanation',
'Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.'
"usage.usageAnalyticsExplanation",
"Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.",
)}
</Text>
<Group gap="sm">
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity')}
onClick={() => navigate("/settings/adminSecurity")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.configureSettings', 'Configure Analytics Settings')}
{t("usage.configureSettings", "Configure Analytics Settings")}
</Button>
<Button
variant="light"
size="xs"
onClick={() => navigate('/settings/adminSecurity#auditLogging')}
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
rightSection={<LocalIcon icon="arrow-forward" width="0.9rem" height="0.9rem" />}
>
{t('usage.viewAuditLogs', 'View Audit Logs')}
{t("usage.viewAuditLogs", "View Audit Logs")}
</Button>
</Group>
</Stack>
@@ -217,20 +211,20 @@ 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={[
{
value: 'top10',
label: t('usage.controls.top10', 'Top 10'),
value: "top10",
label: t("usage.controls.top10", "Top 10"),
},
{
value: 'top20',
label: t('usage.controls.top20', 'Top 20'),
value: "top20",
label: t("usage.controls.top20", "Top 20"),
},
{
value: 'all',
label: t('usage.controls.all', 'All'),
value: "all",
label: t("usage.controls.all", "All"),
},
]}
/>
@@ -241,41 +235,41 @@ const AdminUsageSection: React.FC = () => {
loading={loading}
disabled={showDemoData}
>
{t('usage.controls.refresh', 'Refresh')}
{t("usage.controls.refresh", "Refresh")}
</Button>
</Group>
</Group>
<Group>
<Text size="sm" fw={500}>
{t('usage.controls.dataTypeLabel', 'Data Type:')}
{t("usage.controls.dataTypeLabel", "Data Type:")}
</Text>
<SegmentedControl
value={dataType}
onChange={(value) => setDataType(value as 'all' | 'api' | 'ui')}
onChange={(value) => setDataType(value as "all" | "api" | "ui")}
disabled={showDemoData}
data={[
{
value: 'all',
label: t('usage.controls.dataType.all', 'All'),
value: "all",
label: t("usage.controls.dataType.all", "All"),
},
{
value: 'api',
label: t('usage.controls.dataType.api', 'API'),
value: "api",
label: t("usage.controls.dataType.api", "API"),
},
{
value: 'ui',
label: t('usage.controls.dataType.ui', 'UI'),
value: "ui",
label: t("usage.controls.dataType.ui", "UI"),
},
]}
/>
</Group>
{/* Statistics Summary */}
<Group gap="xl" style={{ flexWrap: 'wrap' }}>
<Group gap="xl" style={{ flexWrap: "wrap" }}>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
{t("usage.stats.totalEndpoints", "Total Endpoints")}
</Text>
<Text size="lg" fw={600}>
{totalEndpoints}
@@ -283,7 +277,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.totalVisits', 'Total Visits')}
{t("usage.stats.totalVisits", "Total Visits")}
</Text>
<Text size="lg" fw={600}>
{totalVisits.toLocaleString()}
@@ -291,7 +285,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.showing', 'Showing')}
{t("usage.stats.showing", "Showing")}
</Text>
<Text size="lg" fw={600}>
{getDisplayModeLabel()}
@@ -299,7 +293,7 @@ const AdminUsageSection: React.FC = () => {
</div>
<div>
<Text size="sm" c="dimmed">
{t('usage.stats.selectedVisits', 'Selected Visits')}
{t("usage.stats.selectedVisits", "Selected Visits")}
</Text>
<Text size="lg" fw={600}>
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
@@ -25,14 +25,14 @@ export default function ApiKeys() {
setTimeout(() => setCopied(null), 1600);
} else {
// Fallback for HTTP: use old execCommand method
const textarea = document.createElement('textarea');
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
if (document.execCommand('copy')) {
if (document.execCommand("copy")) {
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
}
@@ -40,7 +40,7 @@ export default function ApiKeys() {
document.body.removeChild(textarea);
}
} catch (e) {
console.error('Failed to copy:', e);
console.error("Failed to copy:", e);
}
};
@@ -55,7 +55,7 @@ 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
@@ -63,17 +63,17 @@ export default function ApiKeys() {
radius="md"
style={{
background: "var(--bg-muted)",
border: "1px solid var(--border-subtle)"
border: "1px solid var(--border-subtle)",
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<LocalIcon icon="info-rounded" width={18} height={18} style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }} />
<Stack gap={8} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t('config.apiKeys.docsTitle', 'API Documentation')}
{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,9 +81,9 @@ 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')}
{t("config.apiKeys.docsLink", "API Documentation")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
@@ -92,9 +92,9 @@ 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')}
{t("config.apiKeys.schemaLink", "API Schema Reference")}
<LocalIcon icon="open-in-new-rounded" width={14} height={14} />
</Anchor>
</Text>
@@ -105,15 +105,23 @@ export default function ApiKeys() {
{apiKeyError && (
<Text size="sm" c="red.5">
{t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "}
{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')}
{t("common.retry", "Retry")}
</Anchor>
</Text>
)}
{apiKeyLoading ? (
<div style={{ padding: 18, borderRadius: 12, background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<div
style={{
padding: 18,
borderRadius: 12,
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="center" gap={12} wrap="nowrap">
<Skeleton height={36} style={{ flex: 1 }} />
<Skeleton height={32} width={76} />
@@ -131,14 +139,10 @@ 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>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -19,21 +19,21 @@ import {
Avatar,
Box,
type ComboboxItem,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService, User } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
import { useAuth } from '@app/auth/UseSession';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { userManagementService, User } from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import InviteMembersModal from "@app/components/shared/InviteMembersModal";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import { useNavigate } from "react-router-dom";
import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import { useLicense } from "@app/contexts/LicenseContext";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import { useAuth } from "@app/auth/UseSession";
export default function PeopleSection() {
const { t } = useTranslation();
@@ -45,7 +45,7 @@ export default function PeopleSection() {
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
@@ -70,16 +70,16 @@ export default function PeopleSection() {
return;
}
if (hasNoSlots) {
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
return;
}
setInviteModalOpened(true);
};
const addMemberTooltip = !loginEnabled
? t('workspace.people.loginRequired', 'Enable login mode first')
? 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;
@@ -87,7 +87,7 @@ export default function PeopleSection() {
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined as number | undefined,
});
@@ -97,7 +97,7 @@ 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,17 +106,14 @@ 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 => ({
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);
@@ -138,57 +135,57 @@ export default function PeopleSection() {
const exampleUsers: User[] = [
{
id: 1,
username: 'admin',
email: '[email protected]',
username: "admin",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_ADMIN',
rolesAsString: 'ROLE_ADMIN',
authenticationType: 'password',
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
},
{
id: 2,
username: 'john.doe',
email: '[email protected]',
username: "john.doe",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: 'Engineering' }
team: { id: 1, name: "Engineering" },
},
{
id: 3,
username: 'jane.smith',
email: '[email protected]',
username: "jane.smith",
email: "[email protected]",
enabled: true,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'oauth',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "oauth",
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: 'Marketing' }
team: { id: 2, name: "Marketing" },
},
{
id: 4,
username: 'bob.wilson',
email: '[email protected]',
username: "bob.wilson",
email: "[email protected]",
enabled: false,
roleName: 'ROLE_USER',
rolesAsString: 'ROLE_USER',
authenticationType: 'password',
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined
}
team: undefined,
},
];
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 }
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
];
setUsers(exampleUsers);
@@ -207,8 +204,8 @@ export default function PeopleSection() {
});
}
} catch (error) {
console.error('[PeopleSection] Failed to fetch people data:', error);
alert({ alertType: 'error', title: 'Failed to load people data' });
console.error("[PeopleSection] Failed to fetch people data:", error);
alert({ alertType: "error", title: "Failed to load people data" });
} finally {
setLoading(false);
}
@@ -224,15 +221,15 @@ 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);
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');
alert({ alertType: 'error', title: errorMessage });
? 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);
}
@@ -241,53 +238,57 @@ 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') });
alert({ alertType: "success", title: t("workspace.people.toggleEnabled.success") });
fetchData();
} catch (error: unknown) {
console.error('[PeopleSection] Failed to toggle user status:', error);
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');
alert({ alertType: 'error', title: errorMessage });
? 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 });
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
try {
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);
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 });
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
}
};
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);
console.error("[PeopleSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.unlockUserError', 'Failed to unlock user account');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.unlockUserError", "Failed to unlock user account");
alert({ alertType: "error", title: errorMessage });
}
};
@@ -314,36 +315,42 @@ export default function PeopleSection() {
setEditUserModalOpened(false);
setSelectedUser(null);
setEditForm({
role: 'ROLE_USER',
role: "ROLE_USER",
teamId: undefined,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredUsers = users.filter((user) => user.username.toLowerCase().includes(searchQuery.toLowerCase()));
const roleOptions = [
{
value: 'ROLE_ADMIN',
label: t('workspace.people.admin'),
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
icon: 'admin-panel-settings'
value: "ROLE_ADMIN",
label: t("workspace.people.admin"),
description: t(
"workspace.people.roleDescriptions.admin",
"Can manage settings and invite members, with full administrative access.",
),
icon: "admin-panel-settings",
},
{
value: 'ROLE_USER',
label: t('workspace.people.member'),
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
icon: 'person'
value: "ROLE_USER",
label: t("workspace.people.member"),
description: t(
"workspace.people.roleDescriptions.member",
"Can view and edit shared files, but cannot manage workspace settings or members.",
),
icon: "person",
},
];
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="sm" fw={500}>
{option.label}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "normal", lineHeight: 1.4 }}>
{option.description}
</Text>
</Box>
@@ -360,7 +367,7 @@ export default function PeopleSection() {
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.people.loading', 'Loading people...')}
{t("workspace.people.loading", "Loading people...")}
</Text>
</Stack>
);
@@ -371,34 +378,40 @@ export default function PeopleSection() {
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.people.title')}
{t("workspace.people.title")}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.people.description')}
{t("workspace.people.description")}
</Text>
</div>
{/* License Information - Compact */}
{licenseInfo && (
<Group gap="md" style={{ fontSize: '0.875rem' }}>
<Group gap="md" style={{ fontSize: "0.875rem" }}>
<Text size="sm" span c="dimmed">
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
<Text component="span" c="dimmed"> / </Text>
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
<Text component="span" c="dimmed"> {t('workspace.people.license.users', 'users')}</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.totalUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
/{" "}
</Text>
<Text component="span" fw={600} c="inherit">
{licenseInfo.maxAllowedUsers}
</Text>
<Text component="span" c="dimmed">
{" "}
{t("workspace.people.license.users", "users")}
</Text>
</Text>
{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')}
>
{t('workspace.people.actions.upgrade', 'Upgrade')}
<Button size="compact-sm" variant="outline" onClick={() => navigate("/settings/adminPlan")}>
{t("workspace.people.actions.upgrade", "Upgrade")}
</Button>
</Group>
)}
@@ -407,31 +420,32 @@ 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>
)}
{lockedUsers.length > 0 && (
<Badge color="orange" variant="light" size="sm">
{lockedUsers.length} {t('workspace.people.locked', 'locked')}
{lockedUsers.length} {t("workspace.people.locked", "locked")}
</Badge>
)}
{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>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
{globalLicenseInfo?.licenseType === "ENTERPRISE" && (
<>
<Text size="sm" c="dimmed" span></Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
<Text size="sm" c="dimmed" span>
</Text>
<UpdateSeatsButton size="xs" onSuccess={fetchData} />
</>
)}
</Group>
@@ -440,7 +454,7 @@ export default function PeopleSection() {
{/* Header Actions */}
<Group justify="space-between">
<TextInput
placeholder={t('workspace.people.searchMembers')}
placeholder={t("workspace.people.searchMembers")}
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
@@ -457,7 +471,7 @@ export default function PeopleSection() {
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
{t("workspace.people.addMembers")}
</Button>
</Tooltip>
</Group>
@@ -467,20 +481,22 @@ export default function PeopleSection() {
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('workspace.people.user')}
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("workspace.people.user")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
{t('workspace.people.role')}
<Table.Th 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">
{t('workspace.people.team')}
<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>
</Table.Tr>
@@ -490,7 +506,7 @@ export default function PeopleSection() {
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.people.noMembersFound')}
{t("workspace.people.noMembersFound")}
</Text>
</Table.Td>
</Table.Tr>
@@ -498,28 +514,28 @@ 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">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
? t("workspace.people.disabled", "Disabled")
: user.isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
? t("workspace.people.activeSession", "Active session")
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
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,
}
},
}}
>
{user.username.charAt(0).toUpperCase()}
@@ -527,7 +543,11 @@ export default function PeopleSection() {
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Tooltip
label={user.username}
disabled={user.username.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
@@ -535,9 +555,9 @@ export default function PeopleSection() {
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
@@ -545,7 +565,7 @@ export default function PeopleSection() {
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t('workspace.people.lockedBadge', 'Locked')}
{t("workspace.people.lockedBadge", "Locked")}
</Badge>
)}
</Group>
@@ -558,14 +578,10 @@ export default function PeopleSection() {
</Group>
</Table.Td>
<Table.Td w={100}>
<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')}
<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")}
</Badge>
</Table.Td>
<Table.Td>
@@ -575,9 +591,9 @@ export default function PeopleSection() {
size="sm"
maw={150}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.team.name}
@@ -587,36 +603,39 @@ export default function PeopleSection() {
<Text size="sm"></Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
:t('never', 'Never')}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle"size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
<Tooltip
label={
<div>
<Text size="xs" fw={500}>
Authentication: {user.authenticationType || "Unknown"}
</Text>
<Text size="xs">
Last Activity:{" "}
{user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: t("never", "Never")}
</Text>
</div>
}
multiline
w={220}
position="left"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
>
<ActionIcon variant="subtle" size="sm">
<LocalIcon icon="info" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
{/* Actions menu */}
{!isCurrentUser(user) && (
{/* Actions menu */}
{!isCurrentUser(user) && (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<ActionIcon variant="subtle" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
@@ -627,25 +646,31 @@ export default function PeopleSection() {
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" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{t('workspace.people.changePassword.action', 'Change password')}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="lock" width="1rem" height="1rem" />}
onClick={() => openChangePasswordModal(user)}
disabled={!loginEnabled}
>
{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-check" width="1rem" height="1rem" />}
leftSection={
user.enabled ? (
<LocalIcon icon="person-off" 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) && (
@@ -654,7 +679,7 @@ export default function PeopleSection() {
onClick={() => handleUnlockUser(user)}
disabled={!loginEnabled}
>
{t('workspace.people.unlockAccount', 'Unlock Account')}
{t("workspace.people.unlockAccount", "Unlock Account")}
</Menu.Item>
)}
{!isCurrentUser(user) && user.mfaEnabled && (
@@ -666,47 +691,54 @@ export default function PeopleSection() {
onClick={async () => {
try {
await userManagementService.disableMfaByAdmin(user.username);
alert({ alertType: 'success', title: t('workspace.people.mfa.adminDisableSuccess', 'MFA disabled successfully for user') });
alert({
alertType: "success",
title: t(
"workspace.people.mfa.adminDisableSuccess",
"MFA disabled successfully for user",
),
});
} 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.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 });
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>
</>
)}
{!isCurrentUser(user) && (
<>
<Menu.Divider />
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)} disabled={!loginEnabled}>
{t('workspace.people.deleteUser')}
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={!loginEnabled}
>
{t("workspace.people.deleteUser")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
)}
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
{/* Invite Members Modal (reusable) */}
<InviteMembersModal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
/>
<InviteMembersModal opened={inviteModalOpened} onClose={() => setInviteModalOpened(false)} onSuccess={fetchData} />
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
@@ -731,34 +763,34 @@ export default function PeopleSection() {
onClick={closeEditModal}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<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')}
{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')}
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 }}
/>
<Select
label={t('workspace.people.editMember.team')}
placeholder={t('workspace.people.editMember.teamPlaceholder')}
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 })}
@@ -766,7 +798,7 @@ export default function PeopleSection() {
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
{t('workspace.people.editMember.submit')}
{t("workspace.people.editMember.submit")}
</Button>
</Stack>
</Box>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -17,13 +17,13 @@ import {
Menu,
Avatar,
Box,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { User, userManagementService } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import ChangeUserPasswordModal from '@app/components/shared/ChangeUserPasswordModal';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import { User, userManagementService } from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
interface TeamDetailsSectionProps {
teamId: number;
@@ -43,8 +43,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const [changePasswordModalOpened, setChangePasswordModalOpened] = useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
// License information
@@ -64,11 +64,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([
teamService.getTeamDetails(teamId),
userManagementService.getUsers(),
]);
console.log('[TeamDetailsSection] Raw data:', data);
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 : []);
@@ -81,8 +78,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
setMailEnabled(adminData.mailEnabled);
setLockedUsers(adminData.lockedUsers || []);
} catch (error) {
console.error('Failed to fetch team details:', error);
alert({ alertType: 'error', title: t('workspace.teams.loadError', 'Failed to load team details') });
console.error("Failed to fetch team details:", error);
alert({ alertType: "error", title: t("workspace.teams.loadError", "Failed to load team details") });
onBack();
} finally {
setLoading(false);
@@ -94,65 +91,70 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error('Failed to fetch teams:', error);
console.error("Failed to fetch teams:", error);
}
};
const handleAddMember = async () => {
if (!selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.selectUserRequired', 'Please select a user') });
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('');
setSelectedUserId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error('Failed to add member:', error);
console.error("Failed to add member:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.teams.addMemberToTeam.error', 'Failed to add user to team');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.addMemberToTeam.error", "Failed to add user to team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleRemoveMember = async (user: User) => {
if (!window.confirm(t('workspace.teams.confirmRemove', `Remove ${user.username} from this team?`))) {
if (!window.confirm(t("workspace.teams.confirmRemove", `Remove ${user.username} from this team?`))) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find(t => t.name === 'Default');
const defaultTeam = allTeams.find((t) => t.name === "Default");
if (!defaultTeam) {
throw new Error('Default team not found');
throw new Error("Default team not found");
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(user.username, user.rolesAsString || 'ROLE_USER', defaultTeam.id);
alert({ alertType: 'success', title: t('workspace.teams.removeMemberSuccess', 'User removed from team') });
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);
console.error("Failed to remove member:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.teams.removeMemberError', 'Failed to remove user from team');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.removeMemberError", "Failed to remove user from team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
@@ -160,42 +162,43 @@ 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);
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 });
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUnlockUser = async (user: User) => {
const confirmMessage = t('workspace.people.confirmUnlock', 'Are you sure you want to unlock this user account?');
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);
console.error("[TeamDetailsSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.people.unlockUserError', 'Failed to unlock user account');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.unlockUserError", "Failed to unlock user account");
alert({ alertType: "error", title: errorMessage });
}
};
const openChangeTeamModal = (user: User) => {
setSelectedUser(user);
setSelectedTeamId(user.team?.id?.toString() || '');
setSelectedTeamId(user.team?.id?.toString() || "");
setChangeTeamModalOpened(true);
};
@@ -211,25 +214,29 @@ 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;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(selectedUser.username, selectedUser.rolesAsString || 'ROLE_USER', parseInt(selectedTeamId));
alert({ alertType: 'success', title: t('workspace.teams.changeTeam.success', 'Team changed successfully') });
await teamService.moveUserToTeam(
selectedUser.username,
selectedUser.rolesAsString || "ROLE_USER",
parseInt(selectedTeamId),
);
alert({ alertType: "success", title: t("workspace.teams.changeTeam.success", "Team changed successfully") });
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId('');
setSelectedTeamId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error('Failed to change team:', error);
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 });
t("workspace.teams.changeTeam.error", "Failed to change team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
@@ -240,7 +247,7 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Stack align="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loadingDetails', 'Loading team details...')}
{t("workspace.teams.loadingDetails", "Loading team details...")}
</Text>
</Stack>
);
@@ -250,10 +257,10 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
return (
<Stack align="center" py="xl">
<Text size="sm" c="red">
{t('workspace.teams.teamNotFound', 'Team not found')}
{t("workspace.teams.teamNotFound", "Team not found")}
</Text>
<Button variant="light" onClick={onBack}>
{t('workspace.teams.backToTeams', 'Back to Teams')}
{t("workspace.teams.backToTeams", "Back to Teams")}
</Button>
</Stack>
);
@@ -271,7 +278,7 @@ 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>
@@ -279,7 +286,7 @@ 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
@@ -288,9 +295,9 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Button
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')}
{t("workspace.teams.addMember")}
</Button>
</Tooltip>
</Group>
@@ -300,105 +307,110 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('workspace.people.user')}
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("workspace.people.user")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
{t('workspace.people.role')}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w={100}>
{t("workspace.people.role")}
</Table.Th>
<Table.Th w={50}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noMembers', 'No members in this team')}
</Text>
</Table.Td>
</Table.Tr>
) : (
teamUsers.map((user) => {
const isActive = userLastRequest[user.username] &&
(Date.now() - userLastRequest[user.username]) < 5 * 60 * 1000; // Active within last 5 minutes
<Table.Tbody>
{teamUsers.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noMembers", "No members in this team")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teamUsers.map((user) => {
const isActive = userLastRequest[user.username] && Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
return (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t('workspace.people.disabled', 'Disabled')
: isActive
? t('workspace.people.activeSession', 'Active session')
: t('workspace.people.active', 'Active')
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Avatar
size={32}
color={user.enabled ? 'blue' : 'gray'}
styles={{
root: {
border: isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
opacity: user.enabled ? 1 : 0.5,
}
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Text
size="sm"
fw={500}
maw={200}
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
</Text>
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t('workspace.people.lockedBadge', 'Locked')}
</Badge>
)}
</Group>
{user.email && (
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
{user.email}
</Text>
)}
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'cyan'}
variant="light"
return (
<Table.Tr key={user.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Tooltip
label={
!user.enabled
? t("workspace.people.disabled", "Disabled")
: isActive
? t("workspace.people.activeSession", "Active session")
: t("workspace.people.active", "Active")
}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{(user.rolesAsString || '').includes('ROLE_ADMIN')
? t('workspace.people.admin')
: t('workspace.people.member')}
</Badge>
</Table.Td>
<Avatar
size={32}
color={user.enabled ? "blue" : "gray"}
styles={{
root: {
border: isActive ? "2px solid var(--mantine-color-green-6)" : "none",
opacity: user.enabled ? 1 : 0.5,
},
}}
>
{user.username.charAt(0).toUpperCase()}
</Avatar>
</Tooltip>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap" align="center">
<Tooltip
label={user.username}
disabled={user.username.length <= 20}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Text
size="sm"
fw={500}
maw={200}
style={{
lineHeight: 1.3,
opacity: user.enabled ? 1 : 0.6,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user.username}
</Text>
</Tooltip>
{isLockedUser(user) && (
<Badge color="orange" variant="light" size="xs">
{t("workspace.people.lockedBadge", "Locked")}
</Badge>
)}
</Group>
{user.email && (
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
{user.email}
</Text>
)}
</Box>
</Group>
</Table.Td>
<Table.Td w={100}>
<Badge
size="sm"
color={(user.rolesAsString || "").includes("ROLE_ADMIN") ? "blue" : "cyan"}
variant="light"
>
{(user.rolesAsString || "").includes("ROLE_ADMIN")
? t("workspace.people.admin")
: t("workspace.people.member")}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{/* Info icon with tooltip */}
@@ -406,13 +418,13 @@ 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:{' '}
Last Activity:{" "}
{userLastRequest[user.username]
? new Date(userLastRequest[user.username]).toLocaleString()
: 'Never'}
: "Never"}
</Text>
</div>
}
@@ -438,16 +450,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
<Menu.Item
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
onClick={() => openChangeTeamModal(user)}
disabled={processing || team.name === 'Internal'}
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" />}
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
@@ -455,16 +467,16 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
onClick={() => handleUnlockUser(user)}
disabled={processing}
>
{t('workspace.people.unlockAccount', 'Unlock Account')}
{t("workspace.people.unlockAccount", "Unlock Account")}
</Menu.Item>
)}
{team.name !== 'Internal' && team.name !== 'Default' && (
{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.teams.removeMember", "Remove from team")}
</Menu.Item>
)}
<Menu.Divider />
@@ -472,19 +484,19 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteUser(user)}
disabled={processing || team.name === 'Internal'}
disabled={processing || team.name === "Internal"}
>
{t('workspace.people.deleteUser', 'Delete User')}
{t("workspace.people.deleteUser", "Delete User")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
);
})
)}
</Table.Tbody>
</Table>
<ChangeUserPasswordModal
@@ -505,12 +517,12 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
@@ -519,36 +531,36 @@ 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')}
{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')}
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t("workspace.teams.addMemberToTeam.selectUserPlaceholder")}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
@@ -564,12 +576,12 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setChangeTeamModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1,
@@ -578,32 +590,32 @@ 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')}
{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')}
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
placeholder={t("workspace.teams.changeTeam.selectTeamPlaceholder", "Choose a team")}
data={allTeams
.filter((t) => t.name !== 'Internal')
.filter((t) => t.name !== "Internal")
.map((team) => ({
value: team.id.toString(),
label: team.name,
}))}
value={selectedTeamId}
onChange={(value) => setSelectedTeamId(value || '')}
onChange={(value) => setSelectedTeamId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Button onClick={handleChangeTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.changeTeam.submit', 'Change Team')}
{t("workspace.teams.changeTeam.submit", "Change Team")}
</Button>
</Stack>
</div>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
@@ -16,15 +16,15 @@ import {
Select,
CloseButton,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { teamService, Team } from '@app/services/teamService';
import { userManagementService, User } from '@app/services/userManagementService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
} from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import { userManagementService, User } from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
export default function TeamsSection() {
const { t } = useTranslation();
@@ -40,9 +40,9 @@ export default function TeamsSection() {
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
const [newTeamName, setNewTeamName] = useState('');
const [renameTeamName, setRenameTeamName] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [newTeamName, setNewTeamName] = useState("");
const [renameTeamName, setRenameTeamName] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
useEffect(() => {
fetchTeams();
@@ -57,15 +57,15 @@ export default function TeamsSection() {
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: 'Engineering', userCount: 3 },
{ id: 2, name: 'Marketing', userCount: 2 },
{ id: 3, name: 'Internal', userCount: 1 },
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
{ id: 3, name: "Internal", userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error('Failed to fetch teams:', error);
alert({ alertType: 'error', title: 'Failed to load teams' });
console.error("Failed to fetch teams:", error);
alert({ alertType: "error", title: "Failed to load teams" });
} finally {
setLoading(false);
}
@@ -73,23 +73,23 @@ 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') });
setNewTeamName('');
alert({ alertType: "success", title: t("workspace.teams.createTeam.success") });
setNewTeamName("");
setCreateModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error('Failed to create team:', error);
console.error("Failed to create team:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.teams.createTeam.error');
alert({ alertType: 'error', title: errorMessage });
? 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,56 +97,55 @@ 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') });
setRenameTeamName('');
alert({ alertType: "success", title: t("workspace.teams.renameTeam.success") });
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error('Failed to rename team:', error);
console.error("Failed to rename team:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) || t('workspace.teams.renameTeam.error');
alert({ alertType: 'error', title: errorMessage });
? error.response?.data?.message || error.response?.data?.error || error.message
: (error instanceof Error ? error.message : undefined) || t("workspace.teams.renameTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleDeleteTeam = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotDeleteInternal') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotDeleteInternal") });
return;
}
if (!confirm(t('workspace.teams.confirmDelete'))) {
if (!confirm(t("workspace.teams.confirmDelete"))) {
return;
}
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);
console.error("Failed to delete team:", error);
const errorMessage = isAxiosError(error)
? (error.response?.data?.message || error.response?.data?.error || error.message)
: (error instanceof Error ? error.message : undefined) ||
t('workspace.teams.deleteTeam.error');
alert({ alertType: 'error', title: errorMessage });
? 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') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotRenameInternal") });
return;
}
setSelectedTeam(team);
@@ -155,8 +154,8 @@ export default function TeamsSection() {
};
const openAddMemberModal = async (team: Team) => {
if (team.name === 'Internal') {
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
if (team.name === "Internal") {
alert({ alertType: "error", title: t("workspace.teams.cannotAddToInternal") });
return;
}
setSelectedTeam(team);
@@ -166,28 +165,28 @@ export default function TeamsSection() {
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
console.error('Failed to fetch users:', error);
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
console.error("Failed to fetch users:", error);
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.error") });
}
};
const handleAddMember = async () => {
if (!selectedTeam || !selectedUserId) {
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.userRequired') });
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.userRequired") });
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
setSelectedUserId('');
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') });
console.error("Failed to add member to team:", error);
alert({ alertType: "error", title: t("workspace.teams.addMemberToTeam.error") });
} finally {
setProcessing(false);
}
@@ -211,7 +210,7 @@ export default function TeamsSection() {
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('workspace.teams.loading', 'Loading teams...')}
{t("workspace.teams.loading", "Loading teams...")}
</Text>
</Stack>
);
@@ -222,17 +221,21 @@ export default function TeamsSection() {
<LoginRequiredBanner show={!loginEnabled} />
<div>
<Text fw={600} size="lg">
{t('workspace.teams.title')}
{t("workspace.teams.title")}
</Text>
<Text size="sm" c="dimmed">
{t('workspace.teams.description')}
{t("workspace.teams.description")}
</Text>
</div>
{/* Header Actions */}
<Group justify="flex-end">
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)} disabled={!loginEnabled}>
{t('workspace.teams.createNewTeam')}
<Button
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={() => setCreateModalOpened(true)}
disabled={!loginEnabled}
>
{t("workspace.teams.createNewTeam")}
</Button>
</Group>
@@ -242,96 +245,112 @@ export default function TeamsSection() {
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('workspace.teams.teamName')}
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
{t("workspace.teams.teamName")}
</Table.Th>
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
{t('workspace.teams.totalMembers')}
<Table.Th style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--mantine-color-gray-7)" }}>
{t("workspace.teams.totalMembers")}
</Table.Th>
<Table.Th style={{ width: 50 }}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t('workspace.teams.noTeamsFound')}
<Table.Tbody>
{teams.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3}>
<Text ta="center" c="dimmed" py="xl">
{t("workspace.teams.noTeamsFound")}
</Text>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: loginEnabled ? "pointer" : "default" }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Text
size="sm"
fw={500}
maw={200}
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{team.name}
</Text>
</Tooltip>
{team.name === "Internal" && (
<Badge size="xs" color="gray" variant="light">
{t("workspace.teams.system")}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{team.userCount || 0}
</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item
leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />}
onClick={() => setViewingTeamId(team.id)}
disabled={!loginEnabled}
>
{t("workspace.teams.viewTeam", "View Team")}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />}
onClick={() => openAddMemberModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.addMember")}
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />}
onClick={() => openRenameModal(team)}
disabled={!loginEnabled}
>
{t("workspace.teams.renameTeamLabel")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === "Internal"}
>
{t("workspace.teams.deleteTeamLabel")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
) : (
teams.map((team) => (
<Table.Tr
key={team.id}
style={{ cursor: loginEnabled ? 'pointer' : 'default' }}
onClick={() => loginEnabled && setViewingTeamId(team.id)}
>
<Table.Td>
<Group gap="xs">
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
<Text
size="sm"
fw={500}
maw={200}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{team.name}
</Text>
</Tooltip>
{team.name === 'Internal' && (
<Badge size="xs" color="gray" variant="light">
{t('workspace.teams.system')}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">{team.userCount || 0}</Text>
</Table.Td>
<Table.Td onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" disabled={!loginEnabled}>
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)} disabled={!loginEnabled}>
{t('workspace.teams.viewTeam', 'View Team')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.addMember')}
</Menu.Item>
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)} disabled={!loginEnabled}>
{t('workspace.teams.renameTeamLabel')}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
onClick={() => handleDeleteTeam(team)}
disabled={!loginEnabled || team.name === 'Internal'}
>
{t('workspace.teams.deleteTeamLabel')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
))
)}
</Table.Tbody>
</Table>
{/* Create Team Modal */}
@@ -344,36 +363,36 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setCreateModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="group-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<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')}
{t("workspace.teams.createTeam.title")}
</Text>
</Stack>
<TextInput
label={t('workspace.teams.createTeam.teamName')}
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
label={t("workspace.teams.createTeam.teamName")}
placeholder={t("workspace.teams.createTeam.teamNamePlaceholder")}
value={newTeamName}
onChange={(e) => setNewTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.createTeam.submit')}
{t("workspace.teams.createTeam.submit")}
</Button>
</Stack>
</div>
@@ -389,39 +408,39 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setRenameModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<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')}
{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')}
label={t("workspace.teams.renameTeam.newTeamName")}
placeholder={t("workspace.teams.renameTeam.newTeamNamePlaceholder")}
value={renameTeamName}
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
required
/>
<Button onClick={handleRenameTeam} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.renameTeam.submit')}
{t("workspace.teams.renameTeam.submit")}
</Button>
</Stack>
</div>
@@ -437,50 +456,50 @@ export default function TeamsSection() {
padding="xl"
withCloseButton={false}
>
<div style={{ position: 'relative' }}>
<div style={{ position: "relative" }}>
<CloseButton
onClick={() => setAddMemberModalOpened(false)}
size="lg"
style={{
position: 'absolute',
position: "absolute",
top: -8,
right: -8,
zIndex: 1
zIndex: 1,
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<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')}
{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')}
label={t("workspace.teams.addMemberToTeam.selectUser")}
placeholder={t("workspace.teams.addMemberToTeam.selectUserPlaceholder")}
data={availableUsers.map((user) => ({
value: user.id.toString(),
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
}))}
value={selectedUserId}
onChange={(value) => setSelectedUserId(value || '')}
onChange={(value) => setSelectedUserId(value || "")}
searchable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
<Text size="xs" c="orange">
{t('workspace.teams.addMemberToTeam.willBeMoved')}
{t("workspace.teams.addMemberToTeam.willBeMoved")}
</Text>
)}
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
{t('workspace.teams.addMemberToTeam.submit')}
{t("workspace.teams.addMemberToTeam.submit")}
</Button>
</Stack>
</div>
@@ -1,10 +1,5 @@
import React from "react";
import {
Box,
Button,
Group,
Paper,
} from "@mantine/core";
import { Box, Button, Group, Paper } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import FitText from "@app/components/shared/FitText";
import { useTranslation } from "react-i18next";
@@ -17,17 +12,19 @@ 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 (
<>
<Paper radius="md" p={18} style={{ background: "var(--api-keys-card-bg)", border: "1px solid var(--api-keys-card-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)" }}>
<Paper
radius="md"
p={18}
style={{
background: "var(--api-keys-card-bg)",
border: "1px solid var(--api-keys-card-border)",
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
}}
>
<Group align="flex-end" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Box
@@ -36,13 +33,14 @@ export default function ApiKeySection({
border: "1px solid var(--api-keys-input-border)",
borderRadius: 8,
padding: "8px 12px",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14,
minHeight: 36,
display: "flex",
alignItems: "center",
}}
aria-label={t('config.apiKeys.publicKeyAriaLabel', 'Public API key')}
aria-label={t("config.apiKeys.publicKeyAriaLabel", "Public API key")}
>
<FitText text={publicKey} />
</Box>
@@ -52,21 +50,35 @@ export default function ApiKeySection({
variant="light"
onClick={() => onCopy(publicKey, "public")}
leftSection={<LocalIcon icon="content-copy-rounded" width={14} height={14} />}
styles={{ root: { background: "var(--api-keys-button-bg)", color: "var(--api-keys-button-color)", border: "none", marginLeft: 12 } }}
aria-label={t('config.apiKeys.copyKeyAriaLabel', 'Copy API key')}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 12,
},
}}
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
>
{copied === "public" ? t('common.copied', 'Copied!') : t('common.copy', 'Copy')}
{copied === "public" ? t("common.copied", "Copied!") : t("common.copy", "Copy")}
</Button>
<Button
size="sm"
variant="light"
onClick={onRefresh}
leftSection={<LocalIcon icon="refresh-rounded" width={14} height={14} />}
styles={{ root: { background: "var(--api-keys-button-bg)", color: "var(--api-keys-button-color)", border: "none", marginLeft: 8 } }}
styles={{
root: {
background: "var(--api-keys-button-bg)",
color: "var(--api-keys-button-color)",
border: "none",
marginLeft: 8,
},
}}
disabled={disabled}
aria-label={t('config.apiKeys.refreshAriaLabel', 'Refresh API key')}
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
>
{t('common.refresh', 'Refresh')}
{t("common.refresh", "Refresh")}
</Button>
</Group>
</Paper>
@@ -1,11 +1,5 @@
import React from "react";
import {
Modal,
Stack,
Text,
Group,
Button,
} from "@mantine/core";
import { Modal, Stack, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
@@ -21,27 +15,33 @@ export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModa
<Modal
opened={opened}
onClose={onClose}
title={t('config.apiKeys.refreshModal.title', 'Refresh API Keys')}
title={t("config.apiKeys.refreshModal.title", "Refresh API Keys")}
centered
size="sm"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Text size="sm" c="red">
{t('config.apiKeys.refreshModal.warning', '⚠️ Warning: This action will generate new API keys and make your previous keys invalid.')}
{t(
"config.apiKeys.refreshModal.warning",
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
)}
</Text>
<Text size="sm">
{t('config.apiKeys.refreshModal.impact', 'Any applications or services currently using these keys will stop working until you update them with the new keys.')}
{t(
"config.apiKeys.refreshModal.impact",
"Any applications or services currently using these keys will stop working until you update them with the new keys.",
)}
</Text>
<Text size="sm" fw={500}>
{t('config.apiKeys.refreshModal.confirmPrompt', 'Are you sure you want to continue?')}
{t("config.apiKeys.refreshModal.confirmPrompt", "Are you sure you want to continue?")}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('common.cancel', 'Cancel')}
{t("common.cancel", "Cancel")}
</Button>
<Button color="red" onClick={onConfirm}>
{t('config.apiKeys.refreshModal.confirmCta', 'Refresh Keys')}
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
</Button>
</Group>
</Stack>
@@ -78,38 +78,42 @@ export function useApiKey() {
const refresh = useCallback(async () => {
setIsRefreshing(true);
setError(null);
await apiClient.post("/api/v1/user/update-api-key", undefined, {
responseType: "json",
suppressErrorToast: true,
}).then((res) => {
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") {
alert({
alertType: "success",
title: t("config.apiKeys.alert.apiKeyRefreshed", "API Key Refreshed"),
body: t("config.apiKeys.alert.apiKeyRefreshedBody", "Your API key has been successfully refreshed."),
isPersistentPopup: false,
});
setApiKey(value);
} else {
await apiClient
.post("/api/v1/user/update-api-key", undefined, {
responseType: "json",
suppressErrorToast: true,
})
.then((res) => {
const value = typeof res.data === "string" ? res.data : res.data?.apiKey;
if (typeof value === "string") {
alert({
alertType: "success",
title: t("config.apiKeys.alert.apiKeyRefreshed", "API Key Refreshed"),
body: t("config.apiKeys.alert.apiKeyRefreshedBody", "Your API key has been successfully refreshed."),
isPersistentPopup: false,
});
setApiKey(value);
} else {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."),
isPersistentPopup: false,
});
}
})
.catch((e) => {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."),
isPersistentPopup: false,
});
}
}).catch((e) => {
alert({
alertType: "error",
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
body: t("config.apiKeys.alert.failedToRefreshApiKey", "Failed to refresh API key."),
isPersistentPopup: false,
setError(e);
})
.finally(() => {
setIsRefreshing(false);
});
setError(e);
}).finally(() => {
setIsRefreshing(false);
});
}, []);
useEffect(() => {
@@ -1,56 +1,35 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Text,
Group,
Stack,
SegmentedControl,
Loader,
Alert,
Box,
SimpleGrid,
} from '@mantine/core';
import {
AreaChart,
Area,
BarChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { useTranslation } from 'react-i18next';
import auditService, { AuditChartsData } from '@app/services/auditService';
import React, { useState, useEffect } from "react";
import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert, Box, SimpleGrid } from "@mantine/core";
import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import auditService, { AuditChartsData } from "@app/services/auditService";
// Event type color mapping
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: 'var(--mantine-color-green-6)',
USER_LOGOUT: 'var(--mantine-color-gray-5)',
USER_FAILED_LOGIN: 'var(--mantine-color-red-6)',
USER_PROFILE_UPDATE: 'var(--mantine-color-blue-6)',
SETTINGS_CHANGED: 'var(--mantine-color-orange-6)',
FILE_OPERATION: 'var(--mantine-color-cyan-6)',
PDF_PROCESS: 'var(--mantine-color-violet-6)',
UI_DATA: 'var(--mantine-color-teal-6)',
HTTP_REQUEST: 'var(--mantine-color-indigo-6)',
USER_LOGIN: "var(--mantine-color-green-6)",
USER_LOGOUT: "var(--mantine-color-gray-5)",
USER_FAILED_LOGIN: "var(--mantine-color-red-6)",
USER_PROFILE_UPDATE: "var(--mantine-color-blue-6)",
SETTINGS_CHANGED: "var(--mantine-color-orange-6)",
FILE_OPERATION: "var(--mantine-color-cyan-6)",
PDF_PROCESS: "var(--mantine-color-violet-6)",
UI_DATA: "var(--mantine-color-teal-6)",
HTTP_REQUEST: "var(--mantine-color-indigo-6)",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || 'var(--mantine-color-blue-6)';
return EVENT_TYPE_COLORS[type] || "var(--mantine-color-blue-6)";
};
interface AuditChartsSectionProps {
loginEnabled?: boolean;
timePeriod?: 'day' | 'week' | 'month';
onTimePeriodChange?: (period: 'day' | 'week' | 'month') => void;
timePeriod?: "day" | "week" | "month";
onTimePeriodChange?: (period: "day" | "week" | "month") => void;
}
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
loginEnabled = true,
timePeriod = 'week',
timePeriod = "week",
onTimePeriodChange,
}) => {
const { t } = useTranslation();
@@ -66,7 +45,7 @@ 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);
}
@@ -78,15 +57,15 @@ 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: {
labels: ['admin', 'user1', 'user2', 'user3', 'user4'],
labels: ["admin", "user1", "user2", "user3", "user4"],
values: [456, 321, 287, 198, 165],
},
eventsOverTime: {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
values: [123, 145, 167, 189, 201, 87, 65],
},
});
@@ -106,7 +85,7 @@ 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>
);
@@ -137,18 +116,18 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
{/* Header with time period selector */}
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('audit.charts.title', 'Audit Dashboard')}
{t("audit.charts.title", "Audit Dashboard")}
</Text>
<SegmentedControl
value={timePeriod}
onChange={(value) => {
onTimePeriodChange?.(value as 'day' | 'week' | 'month');
onTimePeriodChange?.(value as "day" | "week" | "month");
}}
disabled={!loginEnabled}
data={[
{ label: t('audit.charts.day', 'Day'), value: 'day' },
{ label: t('audit.charts.week', 'Week'), value: 'week' },
{ label: t('audit.charts.month', 'Month'), value: 'month' },
{ label: t("audit.charts.day", "Day"), value: "day" },
{ label: t("audit.charts.week", "Week"), value: "week" },
{ label: t("audit.charts.month", "Month"), value: "month" },
]}
/>
</Group>
@@ -157,9 +136,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.overTime', 'Events Over Time')}
{t("audit.charts.overTime", "Events Over Time")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsOverTimeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={eventsOverTimeData}>
@@ -174,10 +153,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Area
@@ -191,7 +170,7 @@ 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>
@@ -204,9 +183,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byType', 'Events by Type')}
{t("audit.charts.byType", "Events by Type")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsByTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByTypeData}>
@@ -215,10 +194,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<YAxis stroke="var(--mantine-color-gray-6)" />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
@@ -230,7 +209,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
@@ -241,9 +220,9 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="md" fw={600}>
{t('audit.charts.byUser', 'Top Users')}
{t("audit.charts.byUser", "Top Users")}
</Text>
<Box style={{ width: '100%', height: 280 }}>
<Box style={{ width: "100%", height: 280 }}>
{eventsByUserData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={eventsByUserData} layout="vertical">
@@ -252,10 +231,10 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
<YAxis type="category" dataKey="user" stroke="var(--mantine-color-gray-6)" width={100} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--mantine-color-gray-8)',
border: 'none',
borderRadius: 'var(--mantine-radius-md)',
color: 'var(--mantine-color-gray-0)',
backgroundColor: "var(--mantine-color-gray-8)",
border: "none",
borderRadius: "var(--mantine-radius-md)",
color: "var(--mantine-color-gray-0)",
}}
/>
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
@@ -263,7 +242,7 @@ const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
</ResponsiveContainer>
) : (
<Group justify="center">
<Text c="dimmed">{t('audit.charts.noData', 'No data')}</Text>
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
</Group>
)}
</Box>
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { useState } from "react";
import { Card, Stack, Text, PasswordInput, Button, Group, Alert, Code, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditClearDataSectionProps {
loginEnabled?: boolean;
@@ -10,8 +10,8 @@ interface AuditClearDataSectionProps {
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnabled = true }) => {
const { t } = useTranslation();
const [confirmationCode, setConfirmationCode] = useState('');
const [generatedCode, setGeneratedCode] = useState('');
const [confirmationCode, setConfirmationCode] = useState("");
const [generatedCode, setGeneratedCode] = useState("");
const [showConfirmation, setShowConfirmation] = useState(false);
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -20,21 +20,21 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
const handleInitiateDeletion = () => {
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
setGeneratedCode(code);
setConfirmationCode('');
setConfirmationCode("");
setShowConfirmation(true);
setError(null);
};
const resetForm = () => {
setConfirmationCode('');
setGeneratedCode('');
setConfirmationCode("");
setGeneratedCode("");
setShowConfirmation(false);
setError(null);
};
const handleClearData = async () => {
if (confirmationCode !== generatedCode) {
setError(t('audit.clearData.codeDoesNotMatch', 'Code does not match'));
setError(t("audit.clearData.codeDoesNotMatch", "Code does not match"));
return;
}
@@ -47,7 +47,7 @@ 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);
}
@@ -59,12 +59,12 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="green"
icon={<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.success', 'Success')}
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,52 +76,55 @@ 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('audit.clearData.confirmMessage', 'This will permanently remove all audit logs. Enter the confirmation code below to proceed.')}
{t(
"audit.clearData.confirmMessage",
"This will permanently remove all audit logs. Enter the confirmation code below to proceed.",
)}
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
<Card padding="lg" radius="md" withBorder style={{ borderColor: "var(--mantine-color-red-4)" }}>
<Stack gap="md">
<div
style={{
backgroundColor: 'var(--mantine-color-gray-0)',
padding: '1rem',
borderRadius: '0.375rem',
border: '1px solid var(--mantine-color-gray-2)',
backgroundColor: "var(--mantine-color-gray-0)",
padding: "1rem",
borderRadius: "0.375rem",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Text size="xs" fw={600} c="dimmed" mb="xs">
{t('audit.clearData.confirmationCode', 'Confirmation Code')}
{t("audit.clearData.confirmationCode", "Confirmation Code")}
</Text>
<Code
style={{
fontSize: '1.5rem',
letterSpacing: '0.15em',
fontSize: "1.5rem",
letterSpacing: "0.15em",
fontWeight: 600,
display: 'block',
textAlign: 'center',
padding: '0.75rem',
display: "block",
textAlign: "center",
padding: "0.75rem",
}}
>
{generatedCode}
</Code>
<Text size="xs" c="dimmed" mt="xs">
{t('audit.clearData.enterCodeBelow', 'Enter the code exactly as shown above (case-sensitive)')}
{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')}
label={t("audit.clearData.enterCode", "Confirmation Code")}
placeholder={t("audit.clearData.codePlaceholder", "Type the code here")}
value={confirmationCode}
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
disabled={!loginEnabled}
error={
confirmationCode && confirmationCode !== generatedCode
? t('audit.clearData.codeDoesNotMatch', 'Code does not match')
? t("audit.clearData.codeDoesNotMatch", "Code does not match")
: false
}
/>
@@ -134,7 +137,7 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Group justify="space-between">
<Button variant="default" onClick={resetForm} disabled={clearing}>
{t('audit.clearData.cancel', 'Cancel')}
{t("audit.clearData.cancel", "Cancel")}
</Button>
<Button
color="red"
@@ -142,7 +145,7 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
loading={clearing}
disabled={!loginEnabled || !generatedCode || confirmationCode !== generatedCode}
>
{t('audit.clearData.deleteButton', 'Delete')}
{t("audit.clearData.deleteButton", "Delete")}
</Button>
</Group>
</Stack>
@@ -156,31 +159,25 @@ const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({ loginEnab
<Alert
color="red"
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
title={t('audit.clearData.warning1', 'This action cannot be undone')}
title={t("audit.clearData.warning1", "This action cannot be undone")}
>
<Text size="sm">
{t('audit.clearData.warning2', 'Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.')}
{t(
"audit.clearData.warning2",
"Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.",
)}
</Text>
</Alert>
<Card padding="lg" radius="md" withBorder style={{ borderColor: 'var(--mantine-color-red-4)' }}>
<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
>
{t('audit.clearData.initiateDelete', 'Delete All Data')}
<Button color="red" onClick={handleInitiateDeletion} disabled={!loginEnabled} fullWidth>
{t("audit.clearData.initiateDelete", "Delete All Data")}
</Button>
</Stack>
</Card>
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import {
Card,
Text,
@@ -13,13 +13,13 @@ import {
Table,
Badge,
UnstyledButton,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService, { AuditEvent, AuditFilters } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
import LocalIcon from '@app/components/shared/LocalIcon';
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, { AuditEvent, AuditFilters } from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditEventsTableProps {
loginEnabled?: boolean;
@@ -39,17 +39,20 @@ 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 [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
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 () => {
@@ -63,7 +66,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
setEvents(response.events);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load events');
setError(err instanceof Error ? err.message : "Failed to load events");
} finally {
setLoading(false);
}
@@ -76,44 +79,44 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
const now = new Date();
setEvents([
{
id: '1',
id: "1",
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
eventType: 'LOGIN',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'User logged in successfully' },
eventType: "LOGIN",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "User logged in successfully" },
},
{
id: '2',
id: "2",
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
eventType: 'FILE_UPLOAD',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'Uploaded document.pdf' },
eventType: "FILE_UPLOAD",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "Uploaded document.pdf" },
},
{
id: '3',
id: "3",
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
eventType: 'SETTINGS_CHANGE',
username: 'admin',
ipAddress: '192.168.1.100',
details: { message: 'Modified system settings' },
eventType: "SETTINGS_CHANGE",
username: "admin",
ipAddress: "192.168.1.100",
details: { message: "Modified system settings" },
},
{
id: '4',
id: "4",
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
eventType: 'FILE_DOWNLOAD',
username: 'user2',
ipAddress: '192.168.1.102',
details: { message: 'Downloaded report.pdf' },
eventType: "FILE_DOWNLOAD",
username: "user2",
ipAddress: "192.168.1.102",
details: { message: "Downloaded report.pdf" },
},
{
id: '5',
id: "5",
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
eventType: 'LOGOUT',
username: 'user1',
ipAddress: '192.168.1.101',
details: { message: 'User logged out' },
eventType: "LOGOUT",
username: "user1",
ipAddress: "192.168.1.101",
details: { message: "User logged out" },
},
]);
setTotalPages(1);
@@ -137,35 +140,35 @@ 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');
setSortDir(sortDir === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortDir('asc');
setSortDir("asc");
}
};
const getSortIcon = (key: 'timestamp' | 'eventType' | 'username' | 'ipAddress') => {
if (sortKey !== key) return 'unfold-more';
return sortDir === 'asc' ? 'expand-less' : 'expand-more';
const getSortIcon = (key: "timestamp" | "eventType" | "username" | "ipAddress") => {
if (sortKey !== key) return "unfold-more";
return sortDir === "asc" ? "expand-less" : "expand-more";
};
// Event type colors
const EVENT_TYPE_COLORS: Record<string, string> = {
USER_LOGIN: 'green',
USER_LOGOUT: 'gray',
USER_FAILED_LOGIN: 'red',
USER_PROFILE_UPDATE: 'blue',
SETTINGS_CHANGED: 'orange',
FILE_OPERATION: 'cyan',
PDF_PROCESS: 'violet',
UI_DATA: 'gray',
HTTP_REQUEST: 'indigo',
USER_LOGIN: "green",
USER_LOGOUT: "gray",
USER_FAILED_LOGIN: "red",
USER_PROFILE_UPDATE: "blue",
SETTINGS_CHANGED: "orange",
FILE_OPERATION: "cyan",
PDF_PROCESS: "violet",
UI_DATA: "gray",
HTTP_REQUEST: "indigo",
};
const getEventTypeColor = (type: string): string => {
return EVENT_TYPE_COLORS[type] || 'blue';
return EVENT_TYPE_COLORS[type] || "blue";
};
// Apply sorting to current events
@@ -174,19 +177,19 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
let bVal: string | number | undefined;
switch (sortKey) {
case 'timestamp':
case "timestamp":
aVal = new Date(a.timestamp).getTime();
bVal = new Date(b.timestamp).getTime();
break;
case 'eventType':
case "eventType":
aVal = a.eventType;
bVal = b.eventType;
break;
case 'username':
case "username":
aVal = a.username;
bVal = b.username;
break;
case 'ipAddress':
case "ipAddress":
aVal = a.ipAddress;
bVal = b.ipAddress;
break;
@@ -194,8 +197,8 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
return 0;
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
@@ -203,7 +206,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.events.title', 'Audit Events')}
{t("audit.events.title", "Audit Events")}
</Text>
{/* Filters */}
@@ -218,153 +221,161 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
{/* Table */}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ display: "flex", justifyContent: "center" }}>
<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' }}>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('timestamp')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.timestamp', 'Timestamp')}
<LocalIcon icon={getSortIcon('timestamp')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('eventType')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.type', 'Type')}
<LocalIcon icon={getSortIcon('eventType')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)', padding: '0.5rem' }} fz="sm">
<UnstyledButton onClick={() => toggleSort('username')} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none' }}>
{t('audit.events.user', 'User')}
<LocalIcon icon={getSortIcon('username')} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.documentName', 'Document Name')}
</Table.Th>
{showAuthor && (
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.author', 'Author')}
<div style={{ overflowX: "auto", overflowY: "hidden", marginBottom: "1rem" }}>
<Table
horizontalSpacing="md"
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<Table.Thead>
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)", padding: "0.5rem" }} fz="sm">
<UnstyledButton
onClick={() => toggleSort("timestamp")}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", userSelect: "none" }}
>
{t("audit.events.timestamp", "Timestamp")}
<LocalIcon icon={getSortIcon("timestamp")} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
)}
{showFileHash && (
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
{t('audit.events.fileHash', 'File Hash')}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)", padding: "0.5rem" }} fz="sm">
<UnstyledButton
onClick={() => toggleSort("eventType")}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", userSelect: "none" }}
>
{t("audit.events.type", "Type")}
<LocalIcon icon={getSortIcon("eventType")} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)", padding: "0.5rem" }} fz="sm">
<UnstyledButton
onClick={() => toggleSort("username")}
style={{ display: "flex", alignItems: "center", gap: "0.5rem", cursor: "pointer", userSelect: "none" }}
>
{t("audit.events.user", "User")}
<LocalIcon icon={getSortIcon("username")} width="0.9rem" height="0.9rem" />
</UnstyledButton>
</Table.Th>
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("audit.events.documentName", "Document Name")}
</Table.Th>
{showAuthor && (
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("audit.events.author", "Author")}
</Table.Th>
)}
{showFileHash && (
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm">
{t("audit.events.fileHash", "File Hash")}
</Table.Th>
)}
<Table.Th style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" ta="center">
{t("audit.events.actions", "Actions")}
</Table.Th>
)}
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" ta="center">
{t('audit.events.actions', 'Actions')}
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={totalColumns}>
<Group justify="center" py="xl">
<Stack align="center" gap={0}>
<LocalIcon icon="search" width="2rem" height="2rem" style={{ opacity: 0.4 }} />
<Text ta="center" c="dimmed" size="sm">
{t('audit.events.noEvents', 'No events found')}
</Text>
</Stack>
</Group>
</Table.Td>
</Table.Tr>
) : (
sortedEvents.map((event) => {
// Extract document name, author, hash from details.files if available
let documentName = '';
let author = '';
let fileHash = '';
if (event.details && typeof event.details === 'object') {
const details = event.details as Record<string, unknown>;
const files = details.files;
if (Array.isArray(files) && files.length > 0) {
const firstFile = files[0] as Record<string, unknown>;
documentName = typeof firstFile.name === 'string' ? firstFile.name : '';
if (showAuthor || showFileHash) {
author = typeof firstFile.pdfAuthor === 'string' ? firstFile.pdfAuthor : '';
fileHash = typeof firstFile.fileHash === 'string' ? firstFile.fileHash.substring(0, 16) + '...' : '';
</Table.Thead>
<Table.Tbody>
{sortedEvents.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={totalColumns}>
<Group justify="center" py="xl">
<Stack align="center" gap={0}>
<LocalIcon icon="search" width="2rem" height="2rem" style={{ opacity: 0.4 }} />
<Text ta="center" c="dimmed" size="sm">
{t("audit.events.noEvents", "No events found")}
</Text>
</Stack>
</Group>
</Table.Td>
</Table.Tr>
) : (
sortedEvents.map((event) => {
// Extract document name, author, hash from details.files if available
let documentName = "";
let author = "";
let fileHash = "";
if (event.details && typeof event.details === "object") {
const details = event.details as Record<string, unknown>;
const files = details.files;
if (Array.isArray(files) && files.length > 0) {
const firstFile = files[0] as Record<string, unknown>;
documentName = typeof firstFile.name === "string" ? firstFile.name : "";
if (showAuthor || showFileHash) {
author = typeof firstFile.pdfAuthor === "string" ? firstFile.pdfAuthor : "";
fileHash =
typeof firstFile.fileHash === "string" ? firstFile.fileHash.substring(0, 16) + "..." : "";
}
}
}
}
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
{event.eventType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" title={documentName}>
{documentName || '—'}
</Text>
</Table.Td>
{showAuthor && (
return (
<Table.Tr key={event.id}>
<Table.Td>
<Text size="sm">{author}</Text>
<Text size="sm">{formatDate(event.timestamp)}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text size="sm" title={fileHash} style={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{fileHash}
<Badge variant="light" size="sm" color={getEventTypeColor(event.eventType)}>
{event.eventType}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{event.username}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" title={documentName}>
{documentName || "—"}
</Text>
</Table.Td>
)}
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t('audit.events.viewDetails', 'View Details')}
</Button>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{showAuthor && (
<Table.Td>
<Text size="sm">{author}</Text>
</Table.Td>
)}
{showFileHash && (
<Table.Td>
<Text size="sm" title={fileHash} style={{ fontFamily: "monospace", fontSize: "0.75rem" }}>
{fileHash}
</Text>
</Table.Td>
)}
<Table.Td ta="center">
<Button
variant="subtle"
size="xs"
onClick={() => setSelectedEvent(event)}
disabled={!loginEnabled}
>
{t("audit.events.viewDetails", "View Details")}
</Button>
</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination
value={currentPage}
onChange={setCurrentPage}
total={totalPages}
/>
</Group>
)}
{/* Pagination */}
{totalPages > 1 && (
<Group justify="center" mt="md">
<Pagination value={currentPage} onChange={setCurrentPage} total={totalPages} />
</Group>
)}
</div>
</>
)}
@@ -374,7 +385,7 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Modal
opened={selectedEvent !== null}
onClose={() => setSelectedEvent(null)}
title={t('audit.events.eventDetails', 'Event Details')}
title={t("audit.events.eventDetails", "Event Details")}
size="lg"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
@@ -382,33 +393,33 @@ const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
<Stack gap="md">
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.timestamp', 'Timestamp')}
{t("audit.events.timestamp", "Timestamp")}
</Text>
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.type', 'Type')}
{t("audit.events.type", "Type")}
</Text>
<Text size="sm">{selectedEvent.eventType}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.user', 'User')}
{t("audit.events.user", "User")}
</Text>
<Text size="sm">{selectedEvent.username}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.ipAddress', 'IP Address')}
{t("audit.events.ipAddress", "IP Address")}
</Text>
<Text size="sm">{selectedEvent.ipAddress}</Text>
</div>
<div>
<Text size="sm" fw={600} c="dimmed">
{t('audit.events.details', 'Details')}
{t("audit.events.details", "Details")}
</Text>
<Code block mah={300} style={{ overflow: 'auto' }}>
<Code block mah={300} style={{ overflow: "auto" }}>
{JSON.stringify(selectedEvent.details, null, 2)}
</Code>
</div>
@@ -1,18 +1,10 @@
import React, { useState } from 'react';
import {
Card,
Text,
Group,
Stack,
Button,
SegmentedControl,
Checkbox,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useAuditFilters } from '@app/hooks/useAuditFilters';
import AuditFiltersForm from '@app/components/shared/config/configSections/audit/AuditFiltersForm';
import React, { useState } from "react";
import { Card, Text, Group, Stack, Button, SegmentedControl, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAuditFilters } from "@app/hooks/useAuditFilters";
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
interface AuditExportSectionProps {
loginEnabled?: boolean;
@@ -28,7 +20,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
captureOperationResults = false,
}) => {
const { t } = useTranslation();
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
const [exportFormat, setExportFormat] = useState<"csv" | "json">("csv");
const [exporting, setExporting] = useState(false);
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>({
date: true,
@@ -51,13 +43,15 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
try {
setExporting(true);
const fieldsParam = Object.keys(selectedFields).filter(k => selectedFields[k]).join(',');
const fieldsParam = Object.keys(selectedFields)
.filter((k) => selectedFields[k])
.join(",");
const blob = await auditService.exportData(exportFormat, { ...filters, fields: fieldsParam });
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
document.body.appendChild(link);
@@ -65,8 +59,8 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Export failed:', err);
alert(t('audit.export.error', 'Failed to export data'));
console.error("Export failed:", err);
alert(t("audit.export.error", "Failed to export data"));
} finally {
setExporting(false);
}
@@ -76,108 +70,105 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.export.title', 'Export Audit Data')}
{t("audit.export.title", "Export Audit Data")}
</Text>
<Text size="sm" c="dimmed">
{t(
'audit.export.description',
'Export audit events to CSV or JSON format. Use filters to limit the exported data.'
)}
{t("audit.export.description", "Export audit events to CSV or JSON format. Use filters to limit the exported data.")}
</Text>
{/* Format Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.export.format', 'Export Format')}
{t("audit.export.format", "Export Format")}
</Text>
<SegmentedControl
value={exportFormat}
onChange={(value) => {
if (!loginEnabled) return;
setExportFormat(value as 'csv' | 'json');
setExportFormat(value as "csv" | "json");
}}
disabled={!loginEnabled}
data={[
{ label: 'CSV', value: 'csv' },
{ label: 'JSON', value: 'json' },
{ label: "CSV", value: "csv" },
{ label: "JSON", value: "json" },
]}
/>
</div>
{/* Field Selection */}
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.export.selectFields', 'Select Fields to Include')}
</Text>
<Stack gap="xs">
<Text size="sm" fw={600} mb="xs">
{t("audit.export.selectFields", "Select Fields to Include")}
</Text>
<Stack gap="xs">
<Checkbox
label={t("audit.export.fieldDate", "Date")}
checked={selectedFields.date}
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldUsername", "Username")}
checked={selectedFields.username}
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldIpAddress", "IP Address")}
checked={selectedFields.ipaddress}
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldTool", "Tool")}
checked={selectedFields.tool}
onChange={(e) => setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldDocumentName", "Document Name")}
checked={selectedFields.documentName}
onChange={(e) => setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t("audit.export.fieldOutcome", "Outcome (Success/Failure)")}
checked={selectedFields.outcome}
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
{capturePdfAuthor && (
<Checkbox
label={t('audit.export.fieldDate', 'Date')}
checked={selectedFields.date}
onChange={(e) => setSelectedFields({ ...selectedFields, date: e.currentTarget.checked })}
label={t("audit.export.fieldAuthor", "Author (from PDF)")}
checked={selectedFields.author}
onChange={(e) => setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureFileHash && (
<Checkbox
label={t('audit.export.fieldUsername', 'Username')}
checked={selectedFields.username}
onChange={(e) => setSelectedFields({ ...selectedFields, username: e.currentTarget.checked })}
label={t("audit.export.fieldFileHash", "File Hash (SHA-256)")}
checked={selectedFields.fileHash}
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t('audit.export.fieldIpAddress', 'IP Address')}
checked={selectedFields.ipaddress}
onChange={(e) => setSelectedFields({ ...selectedFields, ipaddress: e.currentTarget.checked })}
label={t("audit.export.fieldOperationResults", "Operation Results")}
checked={selectedFields.operationResults}
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldTool', 'Tool')}
checked={selectedFields.tool}
onChange={(e) => setSelectedFields({ ...selectedFields, tool: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldDocumentName', 'Document Name')}
checked={selectedFields.documentName}
onChange={(e) => setSelectedFields({ ...selectedFields, documentName: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
<Checkbox
label={t('audit.export.fieldOutcome', 'Outcome (Success/Failure)')}
checked={selectedFields.outcome}
onChange={(e) => setSelectedFields({ ...selectedFields, outcome: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
{capturePdfAuthor && (
<Checkbox
label={t('audit.export.fieldAuthor', 'Author (from PDF)')}
checked={selectedFields.author}
onChange={(e) => setSelectedFields({ ...selectedFields, author: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureFileHash && (
<Checkbox
label={t('audit.export.fieldFileHash', 'File Hash (SHA-256)')}
checked={selectedFields.fileHash}
onChange={(e) => setSelectedFields({ ...selectedFields, fileHash: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
{captureOperationResults && (
<Checkbox
label={t('audit.export.fieldOperationResults', 'Operation Results')}
checked={selectedFields.operationResults}
onChange={(e) => setSelectedFields({ ...selectedFields, operationResults: e.currentTarget.checked })}
disabled={!loginEnabled}
/>
)}
</Stack>
)}
</Stack>
</div>
{/* Filters */}
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.export.filters', 'Filters (Optional)')}
{t("audit.export.filters", "Filters (Optional)")}
</Text>
<AuditFiltersForm
filters={filters}
@@ -197,7 +188,7 @@ const AuditExportSection: React.FC<AuditExportSectionProps> = ({
loading={exporting}
disabled={!loginEnabled || exporting}
>
{t('audit.export.exportButton', 'Export Data')}
{t("audit.export.exportButton", "Export Data")}
</Button>
</Group>
</Stack>
@@ -1,15 +1,15 @@
import React from 'react';
import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useTranslation } from 'react-i18next';
import { AuditFilters } from '@app/services/auditService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import React from "react";
import { Group, MultiSelect, Button, Stack, SimpleGrid, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useTranslation } from "react-i18next";
import { AuditFilters } from "@app/services/auditService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
const formatDateToYMD = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
@@ -19,17 +19,17 @@ const getDateRange = (preset: string): [Date, Date] | null => {
const start = new Date();
switch (preset) {
case 'today':
case "today":
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
return [start, end];
case 'last7':
case "last7":
start.setDate(start.getDate() - 6);
return [start, end];
case 'last30':
case "last30":
start.setDate(start.getDate() - 29);
return [start, end];
case 'thisMonth':
case "thisMonth":
start.setDate(1);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
@@ -65,8 +65,8 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
const range = getDateRange(preset);
if (range) {
const [start, end] = range;
onFilterChange('startDate', formatDateToYMD(start));
onFilterChange('endDate', formatDateToYMD(end));
onFilterChange("startDate", formatDateToYMD(start));
onFilterChange("endDate", formatDateToYMD(end));
}
};
@@ -85,40 +85,40 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Quick Preset Buttons */}
<div>
<Text size="xs" fw={600} mb="xs" c="dimmed">
{t('audit.filters.quickPresets', 'Quick filters')}
{t("audit.filters.quickPresets", "Quick filters")}
</Text>
<Group gap="xs">
<Button
variant={isPresetActive('today') ? 'filled' : 'light'}
variant={isPresetActive("today") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('today')}
onClick={() => handleQuickPreset("today")}
disabled={disabled}
>
{t('audit.filters.today', 'Today')}
{t("audit.filters.today", "Today")}
</Button>
<Button
variant={isPresetActive('last7') ? 'filled' : 'light'}
variant={isPresetActive("last7") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('last7')}
onClick={() => handleQuickPreset("last7")}
disabled={disabled}
>
{t('audit.filters.last7Days', 'Last 7 days')}
{t("audit.filters.last7Days", "Last 7 days")}
</Button>
<Button
variant={isPresetActive('last30') ? 'filled' : 'light'}
variant={isPresetActive("last30") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('last30')}
onClick={() => handleQuickPreset("last30")}
disabled={disabled}
>
{t('audit.filters.last30Days', 'Last 30 days')}
{t("audit.filters.last30Days", "Last 30 days")}
</Button>
<Button
variant={isPresetActive('thisMonth') ? 'filled' : 'light'}
variant={isPresetActive("thisMonth") ? "filled" : "light"}
size="xs"
onClick={() => handleQuickPreset('thisMonth')}
onClick={() => handleQuickPreset("thisMonth")}
disabled={disabled}
>
{t('audit.filters.thisMonth', 'This month')}
{t("audit.filters.thisMonth", "This month")}
</Button>
</Group>
</div>
@@ -126,39 +126,39 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Filter Inputs */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
<MultiSelect
placeholder={t('audit.events.filterByType', 'Filter by type')}
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 }}
/>
<MultiSelect
placeholder={t('audit.events.filterByUser', 'Filter by user')}
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 }}
/>
<DateInput
placeholder={t('audit.events.startDate', 'Start date')}
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 }}
/>
<DateInput
placeholder={t('audit.events.endDate', 'End date')}
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}
@@ -169,7 +169,7 @@ const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
{/* Clear Button */}
<Group justify="flex-end">
<Button variant="outline" size="sm" onClick={onClearFilters} disabled={disabled}>
{t('audit.events.clearFilters', 'Clear')}
{t("audit.events.clearFilters", "Clear")}
</Button>
</Group>
</Stack>
@@ -1,15 +1,15 @@
import React, { useState, useEffect } from 'react';
import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import auditService, { AuditStats } from '@app/services/auditService';
import LocalIcon from '@app/components/shared/LocalIcon';
import React, { useState, useEffect } from "react";
import { Card, Group, Stack, Text, Badge, SimpleGrid, Loader, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import auditService, { AuditStats } from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
interface AuditStatsCardsProps {
loginEnabled?: boolean;
timePeriod: 'day' | 'week' | 'month';
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 +23,7 @@ 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);
}
@@ -43,8 +43,8 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
avgLatencyMs: 342,
prevAvgLatencyMs: 385,
errorCount: 148,
topEventType: 'PDF_PROCESS',
topUser: 'admin',
topEventType: "PDF_PROCESS",
topUser: "admin",
eventsByType: {},
eventsByUser: {},
topTools: {},
@@ -57,7 +57,7 @@ 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 +66,7 @@ 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>
);
@@ -76,27 +76,30 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
return null;
}
const trendPercent = stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0;
const userTrend = stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0;
const latencyTrend = stats.prevAvgLatencyMs > 0 ? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) / stats.prevAvgLatencyMs) * 100 : 0;
const trendPercent =
stats.prevTotalEvents > 0 ? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) * 100 : 0;
const userTrend =
stats.prevUniqueUsers > 0 ? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) * 100 : 0;
const latencyTrend =
stats.prevAvgLatencyMs > 0 ? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) / stats.prevAvgLatencyMs) * 100 : 0;
const successTrend = stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
const getSuccessRateColor = (rate: number) => {
if (rate >= 95) return 'green';
if (rate >= 80) return 'yellow';
return 'red';
if (rate >= 95) return "green";
if (rate >= 80) return "yellow";
return "red";
};
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
if (lowerIsBetter) {
return trend <= 0 ? 'green' : 'red';
return trend <= 0 ? "green" : "red";
}
return trend >= 0 ? 'green' : 'red';
return trend >= 0 ? "green" : "red";
};
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
return isPositive ? 'trending-up' : 'trending-down';
return isPositive ? "trending-up" : "trending-down";
};
return (
@@ -106,7 +109,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.totalEvents', 'Total Events')}
{t("audit.stats.totalEvents", "Total Events")}
</Text>
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
</Group>
@@ -123,11 +126,11 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
icon={getTrendIcon(trendPercent)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
style={{ marginRight: "0.25rem" }}
/>
}
>
{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>
@@ -138,7 +141,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.successRate', 'Success Rate')}
{t("audit.stats.successRate", "Success Rate")}
</Text>
<LocalIcon icon="check-circle-rounded" width="1.2rem" height="1.2rem" />
</Group>
@@ -148,14 +151,15 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Group gap="xs">
<Badge color={getSuccessRateColor(stats.successRate)} variant="light" size="sm">
{stats.successRate >= 95
? t('audit.stats.excellent', 'Excellent')
? t("audit.stats.excellent", "Excellent")
: stats.successRate >= 80
? t('audit.stats.good', 'Good')
: t('audit.stats.attention', 'Attention needed')}
? t("audit.stats.good", "Good")
: t("audit.stats.attention", "Attention needed")}
</Badge>
{successTrend !== 0 && (
<Badge color={getTrendColor(successTrend)} variant="light" size="xs">
{successTrend > 0 ? '+' : ''}{successTrend.toFixed(1)}%
{successTrend > 0 ? "+" : ""}
{successTrend.toFixed(1)}%
</Badge>
)}
</Group>
@@ -167,7 +171,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.activeUsers', 'Active Users')}
{t("audit.stats.activeUsers", "Active Users")}
</Text>
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
</Group>
@@ -180,12 +184,7 @@ 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)}%
@@ -199,12 +198,12 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
<Stack gap="md">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{t('audit.stats.avgLatency', 'Avg Latency')}
{t("audit.stats.avgLatency", "Avg Latency")}
</Text>
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
</Group>
<Text size="xl" fw={700}>
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t('audit.stats.noData', 'N/A')}
{stats.avgLatencyMs > 0 ? `${stats.avgLatencyMs.toFixed(0)}ms` : t("audit.stats.noData", "N/A")}
</Text>
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
<Badge
@@ -216,7 +215,7 @@ const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({ loginEnabled = true,
icon={getTrendIcon(latencyTrend, true)}
width="0.8rem"
height="0.8rem"
style={{ marginRight: '0.25rem' }}
style={{ marginRight: "0.25rem" }}
/>
}
>
@@ -1,7 +1,7 @@
import React from 'react';
import { Card, Group, Stack, Badge, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AuditSystemStatus as AuditStatus } from '@app/services/auditService';
import React from "react";
import { Card, Group, Stack, Badge, Text, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AuditSystemStatus as AuditStatus } from "@app/services/auditService";
interface AuditSystemStatusProps {
status: AuditStatus;
@@ -14,24 +14,22 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('audit.systemStatus.title', 'System Status')}
{t("audit.systemStatus.title", "System Status")}
</Text>
<Group justify="space-between">
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.status', 'Audit Logging')}
{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>
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.level', 'Audit Level')}
{t("audit.systemStatus.level", "Audit Level")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.level}
@@ -40,16 +38,16 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.retention', 'Retention Period')}
{t("audit.systemStatus.retention", "Retention Period")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.retentionDays} {t('audit.systemStatus.days', 'days')}
{status.retentionDays} {t("audit.systemStatus.days", "days")}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t('audit.systemStatus.totalEvents', 'Total Events')}
{t("audit.systemStatus.totalEvents", "Total Events")}
</Text>
<Text size="lg" fw={600} mt="xs">
{status.totalEvents.toLocaleString()}
@@ -61,34 +59,34 @@ const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
<div>
<Text size="sm" fw={600} mb="xs">
{t('audit.systemStatus.capturedFields', 'Captured Fields')}
{t("audit.systemStatus.capturedFields", "Captured Fields")}
</Text>
<Group gap="xs">
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.username', 'Username')}
{t("audit.systemStatus.username", "Username")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.documentName', 'Document Name')}
{t("audit.systemStatus.documentName", "Document Name")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.tool', 'Tool')}
{t("audit.systemStatus.tool", "Tool")}
</Badge>
<Badge color="green" variant="light" size="sm">
{t('audit.systemStatus.date', 'Date')}
{t("audit.systemStatus.date", "Date")}
</Badge>
<Badge color={status.capturePdfAuthor ? 'green' : 'gray'} variant="light" size="sm">
{t('audit.systemStatus.pdfAuthor', 'PDF Author')}
<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">
{t('audit.systemStatus.fileHash', 'File Hash')}
<Badge color={status.captureFileHash ? "green" : "gray"} variant="light" size="sm">
{t("audit.systemStatus.fileHash", "File Hash")}
{!status.captureFileHash && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
({t('audit.systemStatus.captureBySettings', 'Enable in settings')})
<span style={{ marginLeft: "0.5rem", fontSize: "0.75rem", opacity: 0.7 }}>
({t("audit.systemStatus.captureBySettings", "Enable in settings")})
</span>
)}
</Badge>
@@ -1,11 +1,11 @@
import React, { useState, useMemo } from 'react';
import { Button, Collapse, Select, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils';
import React, { useState, useMemo } from "react";
import { Button, Collapse, Select, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from "@app/utils/planTierUtils";
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -56,23 +56,23 @@ 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' }}>
{t('plan.availablePlans.title', 'Available Plans')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: '0.25rem 0 0 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
margin: "0.25rem 0 0 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
{t("plan.availablePlans.subtitle", "Choose the plan that fits your needs")}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || 'usd')}
onChange={(value) => onCurrencyChange(value || "usd")}
data={currencyOptions}
searchable
clearable={false}
@@ -85,10 +85,10 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.1rem',
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
marginBottom: "0.1rem",
}}
>
{groupedPlans.map((group) => (
@@ -106,11 +106,11 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
))}
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
@@ -1,7 +1,7 @@
import React from 'react';
import { Card, Badge, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanFeature } from '@app/services/licenseService';
import React from "react";
import { Card, Badge, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanFeature } from "@app/services/licenseService";
interface PlanWithFeatures {
name: string;
@@ -12,48 +12,46 @@ interface PlanWithFeatures {
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: 'free' | 'server' | 'enterprise' | null;
currentTier?: "free" | "server" | "enterprise" | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans, currentTier }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
<Card padding="lg" radius="md" withBorder style={{ marginTop: "1rem" }}>
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<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}
style={{
textAlign: 'center',
padding: '0.75rem',
minWidth: '8rem',
position: 'relative'
textAlign: "center",
padding: "0.75rem",
minWidth: "8rem",
position: "relative",
}}
>
{plan.name}
{plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && (
{plan.popular && !(plan.tier === "server" && currentTier === "enterprise") && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
position: "absolute",
top: "0.5rem",
right: "0.5rem",
}}
>
{t('plan.popular', 'Popular')}
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
@@ -62,15 +60,10 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans,
</thead>
<tbody>
{plans[0]?.features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<td style={{ padding: '0.75rem' }}>
{plans[0].features[featureIndex].name}
</td>
<tr key={featureIndex} style={{ borderBottom: "1px solid var(--mantine-color-gray-3)" }}>
<td style={{ padding: "0.75rem" }}>{plans[0].features[featureIndex].name}</td>
{plans.map((plan, planIndex) => (
<td key={planIndex} style={{ textAlign: 'center', padding: '0.75rem' }}>
<td key={planIndex} style={{ textAlign: "center", padding: "0.75rem" }}>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { LicenseInfo } from '@app/services/licenseService';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import React, { useState } from "react";
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { LicenseInfo } from "@app/services/licenseService";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
@@ -17,9 +17,9 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [licenseKeyInput, setLicenseKeyInput] = useState<string>("");
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [inputMethod, setInputMethod] = useState<"text" | "file">("text");
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
@@ -33,17 +33,17 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
let response;
if (inputMethod === 'file' && licenseFile) {
if (inputMethod === "file" && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
} else if (inputMethod === "text" && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key or file"),
});
return;
}
@@ -53,33 +53,33 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
await refetchLicense();
const successMessage =
inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
inputMethod === "file"
? t("admin.settings.premium.file.successMessage", "License file uploaded and activated successfully")
: t("admin.settings.premium.key.successMessage", "License key activated successfully");
alert({
alertType: 'success',
title: t('success', 'Success'),
alertType: "success",
title: t("success", "Success"),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseKeyInput("");
setLicenseFile(null);
setInputMethod('text'); // Reset to default
setInputMethod("text"); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: response.error || t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error('Failed to save license:', error);
console.error("Failed to save license:", error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
@@ -91,15 +91,11 @@ 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">
@@ -107,8 +103,8 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Text size="sm">
{t(
'admin.settings.premium.licenseKey.info',
'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.'
"admin.settings.premium.licenseKey.info",
"If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.",
)}
</Text>
</Alert>
@@ -119,25 +115,25 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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')}
title={t("admin.settings.premium.key.overwriteWarning.title", "⚠️ Warning: Existing License Detected")}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
'admin.settings.premium.key.overwriteWarning.line1',
'Overwriting your current license key cannot be undone.'
"admin.settings.premium.key.overwriteWarning.line1",
"Overwriting your current license key cannot be undone.",
)}
</Text>
<Text size="sm">
{t(
'admin.settings.premium.key.overwriteWarning.line2',
'Your previous license will be permanently lost unless you have backed it up elsewhere.'
"admin.settings.premium.key.overwriteWarning.line2",
"Your previous license will be permanently lost unless you have backed it up elsewhere.",
)}
</Text>
<Text size="sm" fw={500}>
{t(
'admin.settings.premium.key.overwriteWarning.line3',
'Important: Keep license keys private and secure. Never share them publicly.'
"admin.settings.premium.key.overwriteWarning.line3",
"Important: Keep license keys private and secure. Never share them publicly.",
)}
</Text>
</Stack>
@@ -146,24 +142,20 @@ 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}})', {
{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.key", "Source: License key")}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
{t("admin.settings.premium.currentLicense.type", "Type: {{type}}", {
type: currentLicenseInfo.licenseType,
})}
</Text>
@@ -175,19 +167,19 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
setInputMethod(value as "text" | "file");
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
if (value === "text") setLicenseFile(null);
if (value === "file") setLicenseKeyInput("");
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text',
label: t("admin.settings.premium.inputMethod.text", "License Key"),
value: "text",
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file',
label: t("admin.settings.premium.inputMethod.file", "Certificate File"),
value: "file",
},
]}
disabled={!loginEnabled || savingLicense}
@@ -196,17 +188,17 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
{inputMethod === "text" ? (
/* Text input */
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
label={t("admin.settings.premium.key.label", "License Key")}
description={t(
'admin.settings.premium.key.description',
'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.'
"admin.settings.premium.key.description",
"Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
)}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
placeholder={currentLicenseInfo?.licenseKey || "00000000-0000-0000-0000-000000000000"}
type="password"
disabled={!loginEnabled || savingLicense}
/>
@@ -214,16 +206,12 @@ 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}
@@ -231,17 +219,15 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
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}})', {
{t("admin.settings.premium.file.selected", "Selected: {{filename}} ({{size}})", {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
size: (licenseFile.size / 1024).toFixed(2) + " KB",
})}
</Text>
)}
@@ -255,11 +241,11 @@ const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInf
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
(inputMethod === "text" && !licenseKeyInput.trim()) ||
(inputMethod === "file" && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
</Stack>
@@ -1,60 +1,53 @@
import React from 'react';
import { Button, Card, Text, Stack, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
import React from "react";
import { Button, Card, Text, Stack, Divider, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup, LicenseInfo } from "@app/services/licenseService";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import { calculateDisplayPricing } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from "@app/utils/planTierUtils";
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
currentTier?: 'free' | 'server' | 'enterprise' | null;
currentTier?: "free" | "server" | "enterprise" | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick, loginEnabled = true }) => {
const PlanCard: React.FC<PlanCardProps> = ({
planGroup,
isCurrentTier,
isDowngrade,
currentLicenseInfo,
currentTier,
onUpgradeClick,
onManageClick,
loginEnabled = true,
}) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
if (planGroup.tier === "free") {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || '$';
const freeCurrency = planGroup.monthly?.currency || "$";
return (
<Card
padding="lg"
radius="md"
withBorder
style={getBaseCardStyle(isCurrentTier)}
className="plan-card"
>
{isCurrentTier && (
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<Card padding="lg" radius="md" withBorder style={getBaseCardStyle(isCurrentTier)} className="plan-card">
{isCurrentTier && <PricingBadge type="current" label={t("plan.current", "Current Plan")} />}
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
{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 />
@@ -70,9 +63,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<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>
@@ -81,7 +72,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
const isEnterprise = planGroup.tier === "enterprise";
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier);
@@ -89,30 +80,18 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
monthly || undefined,
yearly || 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="popular"
label={t('plan.popular', 'Popular')}
/>
<PricingBadge type="current" label={t("plan.current", "Current Plan")} />
) : planGroup.popular && !(planGroup.tier === "server" && currentTier === "enterprise") ? (
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
) : null}
<Stack gap="md" style={{ height: '100%' }}>
<Stack gap="md" style={{ height: "100%" }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
@@ -120,20 +99,21 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('plan.from', 'From')}
{t("plan.from", "From")}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}{displaySeatPrice.toFixed(2)}
{displayCurrency}
{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t('plan.perSeat', '/seat')}
{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,7 +121,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t('plan.perMonth', '/month')}
period={t("plan.perMonth", "/month")}
/>
)}
</div>
@@ -159,40 +139,39 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<div style={{ flexGrow: 1 }} />
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t("plan.licensedSeats", "Licensed: {{count}} seats", { count: currentLicenseInfo.maxUsers })}
</Text>
)}
{/* Single Upgrade Button */}
<Tooltip
label={t('plan.enterprise.requiresServer', 'Requires Server plan')}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
<Button
variant="filled"
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
className="plan-button"
{/* Single Upgrade Button */}
<Tooltip
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
{isCurrentTier
? t('plan.manage', 'Manage')
: isDowngrade
? t('plan.free.included', 'Included')
: isEnterpriseBlockedForFree
? t('plan.enterprise.requiresServer', 'Requires Server')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Tooltip>
<Button
variant="filled"
fullWidth
onClick={() => (isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup))}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
className="plan-button"
>
{isCurrentTier
? t("plan.manage", "Manage")
: isDowngrade
? t("plan.free.included", "Included")
: isEnterpriseBlockedForFree
? t("plan.enterprise.requiresServer", "Requires Server")
: isEnterprise
? t("plan.selectPlan", "Select Plan")
: t("plan.upgrade", "Upgrade")}
</Button>
</Tooltip>
</Stack>
</Stack>
</Card>
@@ -1,75 +1,70 @@
import React, { useState } from 'react';
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { validateEmail } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from '@app/constants/staticStripeLinks';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useIsMobile } from '@app/hooks/useIsMobile';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
import React, { useState } from "react";
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from "@app/constants/staticStripeLinks";
import { alert } from "@app/components/toast";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useIsMobile } from "@app/hooks/useIsMobile";
import licenseService from "@app/services/licenseService";
import { useLicense } from "@app/contexts/LicenseContext";
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: 'server' | 'enterprise';
planName: "server" | "enterprise";
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
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();
const [stage, setStage] = useState<Stage>('email');
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [stage, setStage] = useState<Stage>("email");
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState('');
const [licenseKey, setLicenseKey] = useState("");
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError('');
setStageHistory([...stageHistory, 'email']);
setStage('period-selection');
setEmailError("");
setStageHistory([...stageHistory, "email"]);
setStage("period-selection");
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
const handlePeriodSelect = (period: "monthly" | "yearly") => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, '_blank');
window.open(urlWithEmail, "_blank");
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
setStageHistory([...stageHistory, "period-selection"]);
setStage("license-activation");
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.premium.noInput", "Please provide a license key"),
});
return;
}
@@ -85,26 +80,23 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
alertType: "success",
title: t("success", "Success"),
body: t("admin.settings.premium.key.successMessage", "License key activated successfully"),
});
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: response.error || t("admin.settings.saveError", "Failed to save license"),
});
}
} catch (error) {
console.error('Failed to save license:', error);
console.error("Failed to save license:", error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save license"),
});
} finally {
setSavingLicense(false);
@@ -124,50 +116,43 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStage("email");
setEmail("");
setEmailError("");
setStageHistory([]);
setLicenseKey('');
setLicenseKey("");
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (stage === "email") {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
return t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
return planName === 'server'
? t('plan.static.getLicense', 'Get Server License')
: t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
return planName === "server"
? t("plan.static.getLicense", "Get Server License")
: t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
}
if (stage === 'period-selection') {
return t('plan.static.selectPeriod', 'Select Billing Period');
if (stage === "period-selection") {
return t("plan.static.selectPeriod", "Select Billing Period");
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
if (stage === "license-activation") {
return t("plan.static.activateLicense", "Activate Your License");
}
return '';
return "";
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case "email":
return <EmailStage emailInput={email} setEmailInput={setEmail} emailError={emailError} onSubmit={handleEmailSubmit} />;
case 'period-selection':
case "period-selection":
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
@@ -175,14 +160,14 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
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')}
{t("payment.monthly", "Monthly")}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
{t("plan.static.monthlyBilling", "Monthly Billing")}
</Text>
</Stack>
</Paper>
@@ -195,14 +180,14 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
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')}
{t("payment.yearly", "Yearly")}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
{t("plan.static.yearlyBilling", "Yearly Billing")}
</Text>
</Stack>
</Paper>
@@ -211,22 +196,18 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
</Stack>
);
case 'license-activation':
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(
'plan.static.licenseActivation.instructions',
'Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.'
"plan.static.licenseActivation.instructions",
"Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.",
)}
</Text>
</Stack>
@@ -237,30 +218,24 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
title={t("plan.static.licenseActivation.success", "License Activated!")}
>
<Text size="sm">
{t(
'plan.static.licenseActivation.successMessage',
'Your license has been successfully activated. You can now close this window.'
"plan.static.licenseActivation.successMessage",
"Your license has been successfully activated. You can now close this window.",
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
'plan.static.licenseActivation.enterKey',
'Enter your license key below to activate your plan:'
)}
{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'
)}
label={t("admin.settings.premium.key.label", "License Key")}
description={t("plan.static.licenseActivation.keyDescription", "Paste the license key from your email")}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
@@ -270,14 +245,10 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
{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>
@@ -285,9 +256,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
<Button onClick={handleClose}>{t("common.close", "Close")}</Button>
</Group>
)}
</Stack>
@@ -298,7 +267,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
const canGoBack = stageHistory.length > 0 && stage !== "license-activation";
return (
<Modal
@@ -307,12 +276,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
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>
)}
@@ -321,7 +285,7 @@ const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
size={isMobile ? "100%" : 600}
centered
radius="lg"
withCloseButton={true}
@@ -1,16 +1,20 @@
import React, { useState } from 'react';
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
import React, { useState } from "react";
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { alert } from "@app/components/toast";
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { STATIC_STRIPE_LINKS } from "@app/constants/staticStripeLinks";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
import {
isCurrentTier as checkIsCurrentTier,
isDowngrade as checkIsDowngrade,
isEnterpriseBlockedForFree,
} from "@app/utils/planTierUtils";
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
@@ -22,19 +26,19 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// 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') {
if (currentTier === "free" && plan === "enterprise") {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
alertType: "warning",
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
"plan.enterprise.requiresServerMessage",
"Please upgrade to the Server plan first before upgrading to Enterprise.",
),
});
return;
@@ -48,84 +52,83 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: 'warning',
title: t('plan.static.billingPortal.title', 'Email Verification Required'),
alertType: "warning",
title: t("plan.static.billingPortal.title", "Email Verification Required"),
body: t(
'plan.static.billingPortal.message',
'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.'
"plan.static.billingPortal.message",
"You will need to verify your email address in the Stripe billing portal. Check your email for a login link.",
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank');
window.open(STATIC_STRIPE_LINKS.billingPortal, "_blank");
};
const staticPlans = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
id: "free",
name: t("plan.free.name", "Free"),
price: 0,
currency: '£',
period: '',
currency: "£",
period: "",
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: 'server',
name: 'Server',
id: "server",
name: "Server",
price: 0,
currency: '',
period: '',
currency: "",
period: "",
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: 'Unlimited users',
maxUsers: "Unlimited users",
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
id: "enterprise",
name: t("plan.enterprise.name", "Enterprise"),
price: 0,
currency: '',
period: '',
currency: "",
period: "",
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: 'Custom',
maxUsers: "Custom",
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === 'enterprise') return staticPlans[2];
if (tier === 'server') return staticPlans[1];
if (tier === "enterprise") return staticPlans[2];
if (tier === "server") return staticPlans[1];
return staticPlans[0]; // free
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Available Plans */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
<h3 style={{ margin: 0, color: "var(--mantine-color-text)", fontSize: "1rem" }}>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
{t("plan.static.contactToUpgrade", "Contact us to upgrade or customize your plan")}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '0.1rem',
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
paddingBottom: "0.1rem",
}}
>
{staticPlans.map((plan) => (
@@ -137,28 +140,20 @@ 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%' }}>
<Stack gap="md" style={{ height: "100%" }}>
<div>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
<Text size="xl" fw={700} style={{ fontSize: "2rem" }}>
{plan.name}
</Text>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
{typeof plan.maxUsers === "string"
? plan.maxUsers
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
: `${t("plan.static.upTo", "Up to")} ${plan.maxUsers} ${t("workspace.people.license.users", "users")}`}
</Text>
</div>
@@ -179,78 +174,56 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
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>
);
}
// Server Plan
if (plan.id === 'server') {
if (currentTier === 'free') {
if (plan.id === "server") {
if (currentTier === "free") {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
onClick={() => handleOpenCheckout("server", false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
{t("plan.upgrade", "Upgrade")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
<Button variant="filled" fullWidth onClick={handleManageBilling} className="plan-button">
{t("plan.manage", "Manage")}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.free.included', 'Included')}
<Button variant="filled" disabled fullWidth className="plan-button">
{t("plan.free.included", "Included")}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
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>
);
}
if (currentTier === 'server') {
if (currentTier === "server") {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
@@ -263,25 +236,15 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
<Button variant="filled" fullWidth disabled className="plan-button">
{t("plan.contact", "Contact Us")}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
<Button variant="filled" fullWidth onClick={handleManageBilling} className="plan-button">
{t("plan.manage", "Manage")}
</Button>
);
}
@@ -295,11 +258,11 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<div style={{ textAlign: "center", marginTop: "1rem" }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
@@ -1,6 +1,6 @@
import React from 'react';
import { Card, Text, Stack, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Card, Text, Stack, Group, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface SimpleBarChartProps {
data: { label: string; value: number }[];
@@ -14,7 +14,7 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
<Stack gap="sm">
{data.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('usage.noData', 'No data available')}
{t("usage.noData", "No data available")}
</Text>
) : (
data.map((item, index) => (
@@ -25,9 +25,9 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
c="dimmed"
maw="60%"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.label}
@@ -43,19 +43,19 @@ const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
</Group>
<Box
style={{
width: '100%',
height: '0.5rem',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden',
width: "100%",
height: "0.5rem",
backgroundColor: "var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-sm)",
overflow: "hidden",
}}
>
<Box
style={{
width: `${(item.value / maxValue) * 100}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease',
height: "100%",
backgroundColor: "var(--mantine-color-blue-6)",
transition: "width 0.3s ease",
}}
/>
</Box>
@@ -83,7 +83,7 @@ const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('usage.chart.title', 'Endpoint Usage Chart')}
{t("usage.chart.title", "Endpoint Usage Chart")}
</Text>
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
</Stack>
@@ -1,17 +1,7 @@
import React from 'react';
import {
Card,
Text,
Stack,
Table,
TableThead,
TableTbody,
TableTr,
TableTh,
TableTd,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { EndpointStatistic } from '@app/services/usageAnalyticsService';
import React from "react";
import { Card, Text, Stack, Table, TableThead, TableTbody, TableTr, TableTh, TableTd } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EndpointStatistic } from "@app/services/usageAnalyticsService";
interface UsageAnalyticsTableProps {
data: EndpointStatistic[];
@@ -24,7 +14,7 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
<Card padding="lg" radius="md" withBorder>
<Stack gap="md">
<Text size="lg" fw={600}>
{t('usage.table.title', 'Detailed Statistics')}
{t("usage.table.title", "Detailed Statistics")}
</Text>
<Table
@@ -32,23 +22,25 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
verticalSpacing="sm"
withRowBorders
highlightOnHover
style={{
'--table-border-color': 'var(--mantine-color-gray-3)',
} as React.CSSProperties}
style={
{
"--table-border-color": "var(--mantine-color-gray-3)",
} as React.CSSProperties
}
>
<TableThead>
<TableTr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="5%">
<TableTr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
<TableTh style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="5%">
#
</TableTh>
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="55%">
{t('usage.table.endpoint', 'Endpoint')}
<TableTh style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="55%">
{t("usage.table.endpoint", "Endpoint")}
</TableTh>
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
{t('usage.table.visits', 'Visits')}
<TableTh style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="20%" ta="right">
{t("usage.table.visits", "Visits")}
</TableTh>
<TableTh style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w="20%" ta="right">
{t('usage.table.percentage', 'Percentage')}
<TableTh style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }} fz="sm" w="20%" ta="right">
{t("usage.table.percentage", "Percentage")}
</TableTh>
</TableTr>
</TableThead>
@@ -57,7 +49,7 @@ const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
<TableTr>
<TableTd colSpan={4}>
<Text ta="center" c="dimmed" py="xl">
{t('usage.table.noData', 'No data available')}
{t("usage.table.noData", "No data available")}
</Text>
</TableTd>
</TableTr>
@@ -1,4 +1,3 @@
.text-divider {
display: flex;
align-items: center;
@@ -1,7 +1,7 @@
import { BASE_PATH } from '@app/constants/app';
import { getLogoFolder } from '@app/constants/logo';
import type { LogoVariant } from '@app/services/preferencesService';
import type { TFunction } from 'i18next';
import { BASE_PATH } from "@app/constants/app";
import { getLogoFolder } from "@app/constants/logo";
import type { LogoVariant } from "@app/services/preferencesService";
import type { TFunction } from "i18next";
export type LoginCarouselSlide = {
src: string;
@@ -13,44 +13,38 @@ 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`;
return [
{
src: heroImage,
alt: t('login.slides.overview.alt', 'Stirling PDF overview'),
title: t('login.slides.overview.title', 'Your one-stop-shop for all your PDF needs.'),
alt: t("login.slides.overview.alt", "Stirling PDF overview"),
title: t("login.slides.overview.title", "Your one-stop-shop for all your PDF needs."),
subtitle: t(
'login.slides.overview.subtitle',
'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.'
"login.slides.overview.subtitle",
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/AddToPDF.png`,
alt: t('login.slides.edit.alt', 'Edit PDFs'),
title: t('login.slides.edit.title', 'Edit PDFs to display/secure the information you want'),
alt: t("login.slides.edit.alt", "Edit PDFs"),
title: t("login.slides.edit.title", "Edit PDFs to display/secure the information you want"),
subtitle: t(
'login.slides.edit.subtitle',
'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.'
"login.slides.edit.subtitle",
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
),
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/SecurePDF.png`,
alt: t('login.slides.secure.alt', 'Secure PDFs'),
title: t('login.slides.secure.title', 'Protect sensitive information in your PDFs'),
subtitle: t(
'login.slides.secure.subtitle',
'Add passwords, redact content, and manage certificates with ease.'
),
alt: t("login.slides.secure.alt", "Secure PDFs"),
title: t("login.slides.secure.title", "Protect sensitive information in your PDFs"),
subtitle: t("login.slides.secure.subtitle", "Add passwords, redact content, and manage certificates with ease."),
followMouseTilt: true,
tiltMaxDeg: 5,
},
@@ -1,38 +1,37 @@
import React, { useEffect } from 'react';
import { Modal, Text, Group, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import licenseService from '@app/services/licenseService';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { StripeCheckoutProps } from '@app/components/shared/stripeCheckout/types/checkout';
import { validateEmail, getModalTitle } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { calculateSavings } from '@app/components/shared/stripeCheckout/utils/savingsCalculator';
import { useCheckoutState } from '@app/components/shared/stripeCheckout/hooks/useCheckoutState';
import { useCheckoutNavigation } from '@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation';
import { useLicensePolling } from '@app/components/shared/stripeCheckout/hooks/useLicensePolling';
import { useCheckoutSession } from '@app/components/shared/stripeCheckout/hooks/useCheckoutSession';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { PlanSelectionStage } from '@app/components/shared/stripeCheckout/stages/PlanSelectionStage';
import { PaymentStage } from '@app/components/shared/stripeCheckout/stages/PaymentStage';
import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/SuccessStage';
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
import React, { useEffect } from "react";
import { Modal, Text, Group, ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import licenseService from "@app/services/licenseService";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { StripeCheckoutProps } from "@app/components/shared/stripeCheckout/types/checkout";
import { validateEmail, getModalTitle } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
import { calculateSavings } from "@app/components/shared/stripeCheckout/utils/savingsCalculator";
import { useCheckoutState } from "@app/components/shared/stripeCheckout/hooks/useCheckoutState";
import { useCheckoutNavigation } from "@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation";
import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/useLicensePolling";
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
'Please add it to your .env file. ' +
'Get your key from https://dashboard.stripe.com/apikeys'
"VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. " +
"Please add it to your .env file. " +
"Get your key from https://dashboard.stripe.com/apikeys",
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
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)}...`,
);
}
@@ -57,7 +56,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
checkoutState.state,
checkoutState.setState,
checkoutState.stageHistory,
checkoutState.setStageHistory
checkoutState.setStageHistory,
);
// Initialize license polling hook
@@ -65,7 +64,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
checkoutState.isMountedRef,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
onLicenseActivated
onLicenseActivated,
);
// Initialize checkout session hook
@@ -82,7 +81,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
polling.pollForLicenseKey,
onSuccess,
onError,
onLicenseActivated
onLicenseActivated,
);
// Calculate savings
@@ -92,17 +91,17 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState(prev => ({ ...prev, email: checkoutState.emailInput }));
navigation.goToStage('plan-selection');
checkoutState.setState((prev) => ({ ...prev, email: checkoutState.emailInput }));
navigation.goToStage("plan-selection");
} else {
checkoutState.setEmailError(validation.error);
}
};
// Plan selection handler
const handlePlanSelect = (period: 'monthly' | 'yearly') => {
const handlePlanSelect = (period: "monthly" | "yearly") => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage('payment');
navigation.goToStage("payment");
};
// Close handler
@@ -136,19 +135,19 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Handle hosted checkout success - open directly to success state
if (hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
console.log("Opening modal to success state for hosted checkout return");
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
checkoutState.setCurrentLicenseKey('existing'); // Flag to indicate upgrade
checkoutState.setPollingStatus('ready');
checkoutState.setCurrentLicenseKey("existing"); // Flag to indicate upgrade
checkoutState.setPollingStatus("ready");
} else if (hostedCheckoutSuccess.licenseKey) {
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
checkoutState.setPollingStatus('ready');
checkoutState.setPollingStatus("ready");
}
// Set to success state to show success UI
checkoutState.setState({ currentStage: 'success', loading: false });
checkoutState.setState({ currentStage: "success", loading: false });
return;
}
@@ -157,32 +156,35 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
try {
const licenseInfo = await licenseService.getLicenseInfo();
// Only skip email if license is PRO or ENTERPRISE (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== 'NORMAL') {
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// Has valid premium license - skip email stage
console.log('Valid premium license detected - skipping 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 });
checkoutState.setState({ currentStage: "email", loading: false });
}
} catch (error) {
console.warn('Could not check for existing license:', error);
console.warn("Could not check for existing license:", error);
// Default to email stage if check fails
checkoutState.setState({ currentStage: 'email', loading: false });
checkoutState.setState({ currentStage: "email", loading: false });
}
};
checkExistingLicense();
}, [opened, hostedCheckoutSuccess, checkoutState.setCurrentLicenseKey, checkoutState.setPollingStatus, checkoutState.setLicenseKey, checkoutState.setState]);
}, [
opened,
hostedCheckoutSuccess,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
checkoutState.setState,
]);
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (
checkoutState.state.currentStage === 'payment' &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
if (checkoutState.state.currentStage === "payment" && !checkoutState.state.clientSecret && !checkoutState.state.loading) {
session.createCheckoutSession();
}
}, [checkoutState.state.currentStage, checkoutState.state.clientSecret, checkoutState.state.loading, session]);
@@ -192,7 +194,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
// Don't block checkout - hosted mode works without publishable key
// The checkout will automatically redirect to Stripe hosted page if key is missing
switch (checkoutState.state.currentStage) {
case 'email':
case "email":
return (
<EmailStage
emailInput={checkoutState.emailInput}
@@ -202,7 +204,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'plan-selection':
case "plan-selection":
return (
<PlanSelectionStage
planGroup={planGroup}
@@ -212,7 +214,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'payment':
case "payment":
return (
<PaymentStage
clientSecret={checkoutState.state.clientSecret || null}
@@ -221,7 +223,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'success':
case "success":
return (
<SuccessStage
pollingStatus={checkoutState.pollingStatus}
@@ -231,13 +233,8 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
/>
);
case 'error':
return (
<ErrorStage
error={checkoutState.state.error || 'An unknown error occurred'}
onClose={handleClose}
/>
);
case "error":
return <ErrorStage error={checkoutState.state.error || "An unknown error occurred"} onClose={handleClose} />;
default:
return null;
@@ -253,12 +250,7 @@ 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>
)}
@@ -278,7 +270,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
styles={{
body: {},
content: {
maxHeight: '95vh',
maxHeight: "95vh",
},
}}
>
@@ -1,10 +1,10 @@
import React from 'react';
import { Text, Stack } from '@mantine/core';
import { formatPrice } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { PRICE_FONT_WEIGHT } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import React from "react";
import { Text, Stack } from "@mantine/core";
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { PRICE_FONT_WEIGHT } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface SimplePriceProps {
mode: 'simple';
mode: "simple";
price: number;
currency: string;
period: string;
@@ -12,21 +12,21 @@ interface SimplePriceProps {
}
interface EnterprisePriceProps {
mode: 'enterprise';
mode: "enterprise";
basePrice: number;
seatPrice: number;
totalPrice?: number;
currency: string;
period: 'month' | 'year';
period: "month" | "year";
seatCount?: number;
size?: 'sm' | 'md' | 'lg';
size?: "sm" | "md" | "lg";
}
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
if (props.mode === 'simple') {
const fontSize = props.size || '2.25rem';
if (props.mode === "simple") {
const fontSize = props.size || "2.25rem";
return (
<>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
@@ -40,9 +40,9 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
}
// Enterprise mode
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = 'md' } = props;
const fontSize = size === 'lg' ? '2rem' : size === 'sm' ? 'md' : 'xl';
const totalFontSize = size === 'lg' ? '2rem' : '2rem';
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = "md" } = props;
const fontSize = size === "lg" ? "2rem" : size === "sm" ? "md" : "xl";
const totalFontSize = size === "lg" ? "2rem" : "2rem";
return (
<Stack gap="sm">
@@ -53,7 +53,7 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(basePrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
{" "}
/{period}
</Text>
</Text>
@@ -65,7 +65,7 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(seatPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
{" "}
/seat/{period}
</Text>
</Text>
@@ -78,8 +78,8 @@ export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
<Text size={totalFontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
/{period === 'year' ? 'month' : period}
{" "}
/{period === "year" ? "month" : period}
</Text>
</Text>
</div>
@@ -1,23 +1,23 @@
import React from 'react';
import { Badge } from '@mantine/core';
import React from "react";
import { Badge } from "@mantine/core";
interface PricingBadgeProps {
type: 'current' | 'popular' | 'savings';
type: "current" | "popular" | "savings";
label: string;
savingsPercent?: number;
}
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
const color = type === 'current' || type === 'savings' ? 'green' : 'blue';
const size = type === 'savings' ? 'lg' : 'sm';
const color = type === "current" || type === "savings" ? "green" : "blue";
const size = type === "savings" ? "lg" : "sm";
return (
<Badge
color={color}
variant="filled"
size={size}
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
className={type === 'current' ? 'current-plan-badge' : undefined}
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
className={type === "current" ? "current-plan-badge" : undefined}
>
{label}
</Badge>
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { CheckoutState, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { useCallback } from "react";
import { CheckoutState, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Stage navigation and history management hook
@@ -8,28 +8,31 @@ export const useCheckoutNavigation = (
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
stageHistory: CheckoutStage[],
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>,
) => {
const goToStage = useCallback((nextStage: CheckoutStage) => {
setStageHistory(prev => [...prev, state.currentStage]);
setState(prev => ({ ...prev, currentStage: nextStage }));
}, [state.currentStage, setState, setStageHistory]);
const goToStage = useCallback(
(nextStage: CheckoutStage) => {
setStageHistory((prev) => [...prev, state.currentStage]);
setState((prev) => ({ ...prev, currentStage: nextStage }));
},
[state.currentStage, setState, setStageHistory],
);
const goBack = useCallback(() => {
if (stageHistory.length > 0) {
const previousStage = stageHistory[stageHistory.length - 1];
setStageHistory(prev => prev.slice(0, -1));
setStageHistory((prev) => prev.slice(0, -1));
// Reset payment state when going back from payment stage
if (state.currentStage === 'payment') {
setState(prev => ({
if (state.currentStage === "payment") {
setState((prev) => ({
...prev,
currentStage: previousStage,
clientSecret: undefined,
loading: false
loading: false,
}));
} else {
setState(prev => ({ ...prev, currentStage: previousStage }));
setState((prev) => ({ ...prev, currentStage: previousStage }));
}
}
}, [stageHistory, state.currentStage, setState, setStageHistory]);
@@ -1,7 +1,7 @@
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 { useCallback } from "react";
import licenseService, { PlanTier } from "@app/services/licenseService";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
import { CheckoutState, PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Checkout session creation and payment handling hook
@@ -19,20 +19,20 @@ 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) {
setState({
currentStage: 'error',
error: 'Selected plan period is not available',
currentStage: "error",
error: "Selected plan period is not available",
loading: false,
});
return;
}
try {
setState(prev => ({ ...prev, loading: true }));
setState((prev) => ({ ...prev, loading: true }));
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
@@ -46,13 +46,13 @@ 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');
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({
@@ -66,49 +66,39 @@ export const useCheckoutSession = (
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log('Redirecting to Stripe hosted checkout:', response.url);
console.log("Redirecting to Stripe hosted checkout:", response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState(prev => ({
setState((prev) => ({
...prev,
clientSecret: response.clientSecret,
sessionId: response.sessionId,
loading: false,
}));
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
const errorMessage = err instanceof Error ? err.message : "Failed to create checkout session";
setState({
currentStage: 'error',
currentStage: "error",
error: errorMessage,
loading: false,
});
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
setState(prev => ({ ...prev, currentStage: 'success' }));
setState((prev) => ({ ...prev, currentStage: "success" }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license with Keygen');
setPollingStatus('polling');
console.log("Upgrade detected - resyncing existing license with Keygen");
setPollingStatus("polling");
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
@@ -117,26 +107,26 @@ export const useCheckoutSession = (
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus('ready');
setPollingStatus("ready");
} else {
console.error('Failed to sync upgraded license:', activation.error);
setPollingStatus('timeout');
console.error("Failed to sync upgraded license:", activation.error);
setPollingStatus("timeout");
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
} else {
// NEW PLAN FLOW: Poll for new license key
console.log('New subscription - polling for license key');
console.log("New subscription - polling for license key");
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || '');
onSuccess?.(state.sessionId || "");
}
}
}, [
@@ -147,7 +137,7 @@ export const useCheckoutSession = (
setPollingStatus,
pollForLicenseKey,
onSuccess,
onLicenseActivated
onLicenseActivated,
]);
return {
@@ -1,50 +1,46 @@
import { useState, useCallback, useRef } from 'react';
import { PlanTierGroup } from '@app/services/licenseService';
import { CheckoutState, PollingStatus, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { useState, useCallback, useRef } from "react";
import { PlanTierGroup } from "@app/services/licenseService";
import { CheckoutState, PollingStatus, CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Centralized state management hook for checkout flow
*/
export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [state, setState] = useState<CheckoutState>({
currentStage: 'email',
loading: false
currentStage: "email",
loading: false,
});
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
const [emailInput, setEmailInput] = useState<string>('');
const [emailError, setEmailError] = useState<string>('');
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
planGroup.yearly ? 'yearly' : 'monthly'
);
const [emailInput, setEmailInput] = useState<string>("");
const [emailError, setEmailError] = useState<string>("");
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(planGroup.yearly ? "yearly" : "monthly");
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>('idle');
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
// Refs for polling cleanup
const isMountedRef = useRef(true);
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly'
? planGroup.yearly
: planGroup.monthly;
const selectedPlan = selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
const resetState = useCallback(() => {
setState({
currentStage: 'email',
currentStage: "email",
loading: false,
clientSecret: undefined,
sessionId: undefined,
error: undefined
error: undefined,
});
setStageHistory([]);
setEmailInput('');
setEmailError('');
setPollingStatus('idle');
setEmailInput("");
setEmailError("");
setPollingStatus("idle");
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
}, [planGroup]);
return {
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
import { useCallback } from "react";
import { pollLicenseKeyWithBackoff, activateLicenseKey } from "@app/utils/licenseCheckoutUtils";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* License key polling and activation logic hook
@@ -9,33 +9,36 @@ 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) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
const pollForLicenseKey = useCallback(
async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
onStatusChange: setPollingStatus,
});
if (!activation.success) {
console.error('Failed to activate license key:', activation.error);
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current ?? false,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error("Failed to activate license key:", activation.error);
}
} else if (result.timedOut) {
console.warn("License key polling timed out");
} else if (result.error) {
console.error("License key polling failed:", result.error);
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
} else if (result.error) {
console.error('License key polling failed:', result.error);
}
}, [isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated]);
},
[isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated],
);
return { pollForLicenseKey };
};
@@ -1,8 +1,8 @@
export { default as StripeCheckout } from '@app/components/shared/stripeCheckout/StripeCheckout';
export { default as StripeCheckout } from "@app/components/shared/stripeCheckout/StripeCheckout";
export type {
StripeCheckoutProps,
CheckoutStage,
CheckoutState,
PollingStatus,
SavingsCalculation
} from '@app/components/shared/stripeCheckout/types/checkout';
SavingsCalculation,
} from "@app/components/shared/stripeCheckout/types/checkout";
@@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Text, TextInput, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Stack, Text, TextInput, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface EmailStageProps {
emailInput: string;
@@ -9,41 +9,32 @@ 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
label={t('payment.emailStage.emailLabel', 'Email Address')}
placeholder={t('payment.emailStage.emailPlaceholder', '[email protected]')}
label={t("payment.emailStage.emailLabel", "Email Address")}
placeholder={t("payment.emailStage.emailPlaceholder", "[email protected]")}
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
error={emailError}
size="lg"
required
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
onSubmit();
}
}}
/>
<Button
size="lg"
onClick={onSubmit}
disabled={!emailInput.trim()}
>
{t('payment.emailStage.continue', 'Continue')}
<Button size="lg" onClick={onSubmit} disabled={!emailInput.trim()}>
{t("payment.emailStage.continue", "Continue")}
</Button>
</Stack>
);
@@ -1,6 +1,6 @@
import React from 'react';
import { Alert, Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Alert, Stack, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ErrorStageProps {
error: string;
@@ -11,11 +11,11 @@ export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Alert color="red" title={t("payment.error", "Payment Error")}>
<Stack gap="md">
<Text size="sm">{error}</Text>
<Button variant="outline" onClick={onClose}>
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -1,9 +1,9 @@
import React from 'react';
import { Stack, Text, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { PlanTier } from '@app/services/licenseService';
import React from "react";
import { Stack, Text, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { loadStripe } from "@stripe/stripe-js";
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { PlanTier } from "@app/services/licenseService";
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
@@ -15,20 +15,16 @@ 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
if (!clientSecret || !selectedPlan) {
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
{t("payment.preparing", "Preparing your checkout...")}
</Text>
</Stack>
);
@@ -38,10 +34,10 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
// This should only happen if embedded mode was attempted without key
// Hosted checkout should have redirected before reaching this component
return (
<Stack align="center" gap="md" style={{ padding: '2rem 0' }}>
<Stack align="center" gap="md" style={{ padding: "2rem 0" }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.redirecting', 'Redirecting to secure checkout...')}
{t("payment.redirecting", "Redirecting to secure checkout...")}
</Text>
</Stack>
);
@@ -49,7 +45,6 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
return (
<Stack gap="md">
{/* Stripe Embedded Checkout */}
<EmbeddedCheckoutProvider
key={clientSecret}
@@ -1,47 +1,39 @@
import React from 'react';
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { formatPrice, calculateMonthlyEquivalent, calculateTotalWithSeats } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import React from "react";
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
import {
formatPrice,
calculateMonthlyEquivalent,
calculateTotalWithSeats,
} from "@app/components/shared/stripeCheckout/utils/pricingUtils";
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
onSelectPlan: (period: 'monthly' | 'yearly') => void;
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 isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => onSelectPlan('monthly')}
>
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<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')}
{t("payment.monthly", "Monthly")}
</Text>
<Divider />
@@ -61,15 +53,15 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || '£'}
period={t('payment.perMonth', '/month')}
currency={planGroup.monthly?.currency || "£"}
period={t("payment.perMonth", "/month")}
size="2.5rem"
/>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="light" fullWidth size="lg">
{t('payment.planStage.selectMonthly', 'Select Monthly')}
{t("payment.planStage.selectMonthly", "Select Monthly")}
</Button>
</div>
</Stack>
@@ -85,18 +77,18 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
p="xl"
radius="md"
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan('yearly')}
onClick={() => onSelectPlan("yearly")}
>
{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')}
{t("payment.yearly", "Yearly")}
</Text>
<Divider />
@@ -108,7 +100,7 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
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"
@@ -116,9 +108,11 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
size="sm"
/>
<Text size="sm" c="dimmed">
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
{t("payment.planStage.billedYearly", "Billed yearly at {{currency}}{{amount}}", {
currency: planGroup.yearly.currency,
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2)
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(
2,
),
})}
</Text>
</Stack>
@@ -127,14 +121,14 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<PriceDisplay
mode="simple"
price={calculateMonthlyEquivalent(planGroup.yearly?.price || 0)}
currency={planGroup.yearly?.currency || '£'}
period={t('payment.perMonth', '/month')}
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}}', {
{t("payment.planStage.billedYearly", "Billed yearly at {{currency}}{{amount}}", {
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2)
amount: planGroup.yearly?.price.toFixed(2),
})}
</Text>
</Stack>
@@ -143,16 +137,16 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
{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>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
<Button variant="filled" fullWidth size="lg">
{t('payment.planStage.selectYearly', 'Select Yearly')}
{t("payment.planStage.selectYearly", "Select Yearly")}
</Button>
</div>
</Stack>
@@ -1,7 +1,7 @@
import React from 'react';
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
import React from "react";
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
interface SuccessStageProps {
pollingStatus: PollingStatus;
@@ -10,90 +10,76 @@ 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!')}>
<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' && (
{pollingStatus === "polling" && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t('payment.syncingLicense', 'Syncing your upgraded license...')
: t('payment.generatingLicense', 'Generating your license key...')}
? t("payment.syncingLicense", "Syncing your upgraded license...")
: t("payment.generatingLicense", "Generating your license key...")}
</Text>
</Group>
)}
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
{pollingStatus === "ready" && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('payment.licenseKey', 'Your License Key')}
{t("payment.licenseKey", "Your License Key")}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t('common.copy', 'Copy to Clipboard')}
<Button variant="light" size="sm" onClick={() => navigator.clipboard.writeText(licenseKey)}>
{t("common.copy", "Copy to Clipboard")}
</Button>
<Text size="xs" c="dimmed">
{t(
'payment.licenseInstructions',
'This has been added to your installation. You will receive a copy in your email as well.'
"payment.licenseInstructions",
"This has been added to your installation. You will receive a copy in your email as well.",
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === 'ready' && currentLicenseKey && (
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
{pollingStatus === "ready" && currentLicenseKey && (
<Alert color="green" title={t("payment.upgradeComplete", "Upgrade Complete")}>
<Text size="sm">
{t(
'payment.upgradeCompleteMessage',
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
"payment.upgradeCompleteMessage",
"Your subscription has been upgraded successfully. Your existing license key has been updated.",
)}
</Text>
</Alert>
)}
{pollingStatus === 'timeout' && (
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
{pollingStatus === "timeout" && (
<Alert color="yellow" title={t("payment.licenseDelayed", "License Key Processing")}>
<Text size="sm">
{t(
'payment.licenseDelayedMessage',
'Your license key is being generated. Please check your email shortly or contact support.'
"payment.licenseDelayedMessage",
"Your license key is being generated. Please check your email shortly or contact support.",
)}
</Text>
</Alert>
)}
{pollingStatus === 'ready' && (
{pollingStatus === "ready" && (
<Text size="xs" c="dimmed">
{t('payment.canCloseWindow', 'You can now close this window.')}
{t("payment.canCloseWindow", "You can now close this window.")}
</Text>
)}
<Button onClick={onClose} mt="md">
{t('common.close', 'Close')}
{t("common.close", "Close")}
</Button>
</Stack>
</Alert>
@@ -1,4 +1,4 @@
import { PlanTierGroup } from '@app/services/licenseService';
import { PlanTierGroup } from "@app/services/licenseService";
export interface StripeCheckoutProps {
opened: boolean;
@@ -7,14 +7,14 @@ 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;
@@ -25,7 +25,7 @@ export type CheckoutState = {
loading?: boolean;
};
export type PollingStatus = 'idle' | 'polling' | 'ready' | 'timeout';
export type PollingStatus = "idle" | "polling" | "ready" | "timeout";
export interface SavingsCalculation {
amount: number;
@@ -1,10 +1,10 @@
import { CSSProperties } from 'react';
import { CSSProperties } from "react";
/**
* Shared styling utilities for plan cards
*/
export const CARD_MIN_HEIGHT = '400px';
export const CARD_MIN_HEIGHT = "400px";
export const PRICE_FONT_WEIGHT = 600;
/**
@@ -12,8 +12,8 @@ export const PRICE_FONT_WEIGHT = 600;
*/
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
return {
borderColor: isHighlighted ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isHighlighted ? '2px' : undefined,
borderColor: isHighlighted ? "var(--mantine-color-green-6)" : undefined,
borderWidth: isHighlighted ? "2px" : undefined,
};
}
@@ -22,9 +22,9 @@ export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
*/
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
return {
position: 'relative',
display: 'flex',
flexDirection: 'column',
position: "relative",
display: "flex",
flexDirection: "column",
minHeight: CARD_MIN_HEIGHT,
...getCardBorderStyle(isHighlighted),
};
@@ -35,10 +35,10 @@ export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties
*/
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
return {
cursor: 'pointer',
transition: 'all 0.2s',
height: '100%',
position: 'relative',
cursor: "pointer",
transition: "all 0.2s",
height: "100%",
position: "relative",
...getCardBorderStyle(isHighlighted),
};
}
@@ -1,5 +1,5 @@
import { TFunction } from 'i18next';
import { CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
import { TFunction } from "i18next";
import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Validate email address format
@@ -9,32 +9,28 @@ export const validateEmail = (email: string): { valid: boolean; error: string }
if (!emailRegex.test(email)) {
return {
valid: false,
error: 'Please enter a valid email address'
error: "Please enter a valid email address",
};
}
return { valid: true, error: '' };
return { valid: true, error: "" };
};
/**
* 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 });
case 'plan-selection':
return t('payment.planStage.modalTitle', 'Select Billing Period - {{planName}}', { planName });
case 'payment':
return t('payment.paymentStage.modalTitle', 'Complete Payment - {{planName}}', { planName });
case 'success':
return t('payment.success', 'Payment Successful!');
case 'error':
return t('payment.error', 'Payment Error');
case "email":
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", { planName });
case "plan-selection":
return t("payment.planStage.modalTitle", "Select Billing Period - {{planName}}", { planName });
case "payment":
return t("payment.paymentStage.modalTitle", "Complete Payment - {{planName}}", { planName });
case "success":
return t("payment.success", "Payment Successful!");
case "error":
return t("payment.error", "Payment Error");
default:
return t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName });
return t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName });
}
};
@@ -18,11 +18,7 @@ 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;
}
@@ -40,14 +36,14 @@ export function formatPrice(amount: number, currency: string, decimals: number =
*/
export function calculateDisplayPricing(
monthly?: { price: number; seatPrice?: number; currency: string },
yearly?: { price: number; seatPrice?: number; currency: string }
yearly?: { price: number; seatPrice?: number; currency: string },
): PriceCalculation {
// Default to monthly if no yearly exists
if (!yearly) {
return {
displayPrice: monthly?.price || 0,
displaySeatPrice: monthly?.seatPrice,
displayCurrency: monthly?.currency || '£',
displayCurrency: monthly?.currency || "£",
};
}
@@ -1,17 +1,14 @@
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
import { PlanTierGroup } from "@app/services/licenseService";
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
/**
* Calculate savings for yearly vs monthly plans
* Returns null if both monthly and yearly plans are not available
*/
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number
): SavingsCalculation | null => {
export const calculateSavings = (planGroup: PlanTierGroup, minimumSeats: number): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === 'enterprise';
const isEnterprise = planGroup.tier === "enterprise";
const seatCount = minimumSeats || 1;
let monthlyAnnual: number;
@@ -19,8 +16,8 @@ export const calculateSavings = (
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;
@@ -33,6 +30,6 @@ export const calculateSavings = (
return {
amount: savings,
percent: savingsPercent,
currency: planGroup.yearly.currency
currency: planGroup.yearly.currency,
};
};
@@ -1,23 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Card,
Text,
Badge,
Group,
Button,
Loader,
Alert,
TextInput,
FileInput,
Select,
} from '@mantine/core';
import { useParticipantSession } from '@app/hooks/workflow/useParticipantSession';
import InfoIcon from '@mui/icons-material/Info';
import DownloadIcon from '@mui/icons-material/Download';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Card, Text, Badge, Group, Button, Loader, Alert, TextInput, FileInput, Select } from "@mantine/core";
import { useParticipantSession } from "@app/hooks/workflow/useParticipantSession";
import InfoIcon from "@mui/icons-material/Info";
import DownloadIcon from "@mui/icons-material/Download";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CancelIcon from "@mui/icons-material/Cancel";
interface ParticipantViewProps {
token: string;
@@ -25,63 +13,68 @@ 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>('');
const [certType, setCertType] = useState<string>("P12");
const [password, setPassword] = useState<string>("");
const [certFile, setCertFile] = useState<File | null>(null);
const [location, setLocation] = useState<string>('');
const [reason, setReason] = useState<string>('Document Signing');
const [location, setLocation] = useState<string>("");
const [reason, setReason] = useState<string>("Document Signing");
const [showSignature, _setShowSignature] = useState<boolean>(true);
const [pageNumber, setPageNumber] = useState<number>(1);
const [declineReason, _setDeclineReason] = useState<string>('');
const [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' }
| { status: 'validating' }
| { status: 'valid'; notAfter: string | null; subjectName: string | null }
| { status: 'error'; message: string };
| { status: "idle" }
| { status: "validating" }
| { 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(() => {
if (certType === 'SERVER' || !certFile) {
setCertValidation({ status: 'idle' });
if (certType === "SERVER" || !certFile) {
setCertValidation({ status: "idle" });
return;
}
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
setCertValidation({ status: 'validating' });
setCertValidation({ status: "validating" });
validationTimerRef.current = setTimeout(async () => {
try {
const formData = new FormData();
formData.append('participantToken', token);
formData.append('certType', certType);
formData.append('password', password);
if (certType === 'JKS') {
formData.append('jksFile', certFile);
formData.append("participantToken", token);
formData.append("certType", certType);
formData.append("password", password);
if (certType === "JKS") {
formData.append("jksFile", certFile);
} else {
formData.append('p12File', certFile);
formData.append("p12File", certFile);
}
const res = await fetch('/api/v1/workflow/participant/validate-certificate', {
method: 'POST',
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') });
setCertValidation({
status: "error",
message: data.error ?? t("certSign.collab.participant.certInvalidFallback", "Invalid certificate"),
});
}
} catch {
setCertValidation({ status: 'error', message: t('certSign.collab.participant.certNetworkError', 'Could not validate certificate') });
setCertValidation({
status: "error",
message: t("certSign.collab.participant.certNetworkError", "Could not validate certificate"),
});
}
}, 600);
@@ -91,8 +84,8 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
}, [certFile, password, certType, token]);
const handleSubmitSignature = async () => {
if (!certFile && certType !== 'SERVER') {
setNotification({ type: 'error', message: 'Please select a certificate file' });
if (!certFile && certType !== "SERVER") {
setNotification({ type: "error", message: "Please select a certificate file" });
return;
}
@@ -103,30 +96,33 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
participantToken: token,
certType,
password,
p12File: certType === 'P12' ? certFile || undefined : undefined,
jksFile: certType === 'JKS' ? certFile || undefined : undefined,
p12File: certType === "P12" ? certFile || undefined : undefined,
jksFile: certType === "JKS" ? certFile || undefined : undefined,
showSignature,
pageNumber,
location,
reason,
showLogo: true,
});
setNotification({ type: 'success', message: 'Signature submitted successfully!' });
setNotification({ type: "success", message: "Signature submitted successfully!" });
} catch (err: unknown) {
setNotification({ type: 'error', message: `Failed to submit signature: ${err instanceof Error ? err.message : String(err)}` });
setNotification({
type: "error",
message: `Failed to submit signature: ${err instanceof Error ? err.message : String(err)}`,
});
} finally {
setIsSubmitting(false);
}
};
const handleDecline = async () => {
if (window.confirm('Are you sure you want to decline signing this document?')) {
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.' });
await decline(token, declineReason || "Declined by participant");
setNotification({ type: "success", message: "You have declined this signing request." });
} catch (err: unknown) {
setNotification({ type: 'error', message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}` });
setNotification({ type: "error", message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}` });
}
}
};
@@ -158,29 +154,29 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
const getStatusBadge = (status: string) => {
switch (status) {
case 'SIGNED':
case "SIGNED":
return <Badge color="green">Signed</Badge>;
case 'DECLINED':
case "DECLINED":
return <Badge color="red">Declined</Badge>;
case 'VIEWED':
case "VIEWED":
return <Badge color="blue">Viewed</Badge>;
case 'NOTIFIED':
case "NOTIFIED":
return <Badge color="yellow">Notified</Badge>;
case 'PENDING':
case "PENDING":
return <Badge color="gray">Pending</Badge>;
default:
return <Badge>{status}</Badge>;
}
};
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" />}
color={notification.type === 'success' ? 'green' : 'red'}
icon={notification.type === "success" ? <CheckCircleIcon fontSize="small" /> : <InfoIcon fontSize="small" />}
color={notification.type === "success" ? "green" : "red"}
withCloseButton
onClose={() => setNotification(null)}
>
@@ -236,17 +232,17 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
<Select
label="Certificate Type"
value={certType}
onChange={(value) => setCertType(value || 'P12')}
onChange={(value) => setCertType(value || "P12")}
data={[
{ value: 'P12', label: 'P12/PKCS12 Certificate' },
{ value: 'JKS', label: 'JKS Keystore' },
{ value: 'SERVER', label: 'Server Certificate (if available)' },
{ value: "P12", label: "P12/PKCS12 Certificate" },
{ value: "JKS", label: "JKS Keystore" },
{ value: "SERVER", label: "Server Certificate (if available)" },
]}
size="sm"
data-testid="cert-type-select"
/>
{certType !== 'SERVER' && (
{certType !== "SERVER" && (
<>
<FileInput
label="Certificate File"
@@ -268,25 +264,25 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
/>
{/* Certificate validation feedback */}
{certValidation.status === 'validating' && (
{certValidation.status === "validating" && (
<Text size="sm" c="dimmed" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValidating', 'Validating certificate...')}
{t("certSign.collab.participant.certValidating", "Validating certificate...")}
</Text>
)}
{certValidation.status === 'valid' && (
{certValidation.status === "valid" && (
<Text size="sm" c="green" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certValid', '✓ Certificate valid')}
{t("certSign.collab.participant.certValid", "✓ Certificate valid")}
{certValidation.notAfter
? t('certSign.collab.participant.certValidUntil', ' until {{date}}', {
? 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' && (
{certValidation.status === "error" && (
<Text size="sm" c="red" data-testid="cert-validation-feedback">
{t('certSign.collab.participant.certInvalid', '✗ {{error}}', { error: certValidation.message })}
{t("certSign.collab.participant.certInvalid", "✗ {{error}}", { error: certValidation.message })}
</Text>
)}
</>
@@ -322,7 +318,7 @@ 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"
>
@@ -345,7 +341,7 @@ 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>
)}
@@ -1,4 +1,4 @@
import type { PlanFeature } from '@app/types/license';
import type { PlanFeature } from "@app/types/license";
/**
* Shared plan feature definitions for Stirling PDF Self-Hosted
@@ -7,98 +7,94 @@ import type { PlanFeature } from '@app/types/license';
export const PLAN_FEATURES = {
FREE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: true },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: false },
{ name: 'External Database', included: false },
{ name: 'Editing text in pdfs', included: false },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
{ name: 'SAML', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: true },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: false },
{ name: "External Database", included: false },
{ name: "Editing text in pdfs", included: false },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: false },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
SERVER: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Up to 5 users', included: false },
{ name: 'Unlimited users', included: true },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: true },
{ name: 'SAML', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "Up to 5 users", included: false },
{ name: "Unlimited users", included: true },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: false },
{ name: "SSO", included: true },
{ name: "SAML", included: false },
{ name: "Auditing", included: false },
{ name: "Usage tracking", included: false },
{ name: "Prometheus Support", included: false },
{ name: "Custom PDF metadata", included: false },
] as PlanFeature[],
ENTERPRISE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: false },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: true },
{ name: 'SSO', included: true },
{ name: 'SAML', included: true },
{ name: 'Auditing', included: true },
{ name: 'Usage tracking', included: true },
{ name: 'Prometheus Support', included: true },
{ name: 'Custom PDF metadata', included: true },
{ name: "Self-hosted deployment", included: true },
{ name: "All PDF operations", included: true },
{ name: "Secure Login Support", included: true },
{ name: "Community support", included: true },
{ name: "Regular updates", included: true },
{ name: "up to 5 users", included: false },
{ name: "Unlimited users", included: false },
{ name: "Google drive integration", included: true },
{ name: "External Database", included: true },
{ name: "Editing text in pdfs", included: true },
{ name: "Users limited to seats", included: true },
{ name: "SSO", included: true },
{ name: "SAML", included: true },
{ name: "Auditing", included: true },
{ name: "Usage tracking", included: true },
{ name: "Prometheus Support", included: true },
{ name: "Custom PDF metadata", included: true },
] as PlanFeature[],
} as const;
export const PLAN_HIGHLIGHTS = {
FREE: [
'Up to 5 users',
'Self-hosted',
'All basic features'
],
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
SERVER_MONTHLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'SSO (OAuth2/OIDC)',
'Editing text in PDFs',
'Cancel anytime'
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Cancel anytime",
],
SERVER_YEARLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'SSO (OAuth2/OIDC)',
'Editing text in PDFs',
'Save with annual billing'
"Self-hosted on your infrastructure",
"Unlimited users",
"Advanced integrations",
"SSO (OAuth2/OIDC)",
"Editing text in PDFs",
"Save with annual billing",
],
ENTERPRISE_MONTHLY: [
'Enterprise features (SAML, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Per-seat licensing'
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Per-seat licensing",
],
ENTERPRISE_YEARLY: [
'Enterprise features (SAML, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Save with annual billing'
]
"Enterprise features (SAML, Auditing)",
"Usage tracking & Prometheus",
"Custom PDF metadata",
"Save with annual billing",
],
} as const;
@@ -20,14 +20,14 @@ export interface StaticStripeLinks {
// PRODCUTION LINKS FOR LIVE SERVER
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
server: {
monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03',
yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02',
monthly: "https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03",
yearly: "https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02",
},
enterprise: {
monthly: '',
yearly: '',
monthly: "",
yearly: "",
},
billingPortal: 'https://billing.stripe.com/p/login/5kA6pT6Xa7z59HO4gg',
billingPortal: "https://billing.stripe.com/p/login/5kA6pT6Xa7z59HO4gg",
};
// LINKS FOR TEST SERVER:
@@ -1,26 +1,23 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier, PlanTier } from '@app/services/licenseService';
import { StripeCheckout } from '@app/components/shared/stripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { useLicense } from '@app/contexts/LicenseContext';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency } from '@app/utils/currencyDetection';
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier, PlanTier } from "@app/services/licenseService";
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
import { userManagementService } from "@app/services/userManagementService";
import { alert } from "@app/components/toast";
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
import { useLicense } from "@app/contexts/LicenseContext";
import { isSupabaseConfigured } from "@app/services/supabaseClient";
import { getPreferredCurrency } from "@app/utils/currencyDetection";
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (auto-detected from locale)
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (auto-detected from locale)
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
interface CheckoutContextValue {
openCheckout: (
tier: 'server' | 'enterprise',
options?: CheckoutOptions
) => Promise<void>;
openCheckout: (tier: "server" | "enterprise", options?: CheckoutOptions) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
@@ -33,10 +30,7 @@ interface CheckoutProviderProps {
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);
@@ -59,22 +53,25 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
const [plansLoading, setPlansLoading] = useState(false);
// Lazy fetch plans only when needed
const fetchPlansIfNeeded = useCallback(async (currency: string) => {
// Don't fetch if already loading
if (plansLoading) return;
const fetchPlansIfNeeded = useCallback(
async (currency: string) => {
// Don't fetch if already loading
if (plansLoading) return;
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
console.error('Failed to fetch plans:', error);
// Don't block - let components handle the error
} finally {
setPlansLoading(false);
}
}, [plansLoading]);
try {
setPlansLoading(true);
const response = await licenseService.getPlans(currency);
setPlans(response.plans);
setPlansLoaded(true);
} catch (error) {
console.error("Failed to fetch plans:", error);
// Don't block - let components handle the error
} finally {
setPlansLoading(false);
}
},
[plansLoading],
);
const refetchPlans = useCallback(() => {
setPlansLoaded(false); // Force refetch
@@ -85,33 +82,33 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
useEffect(() => {
const handleCheckoutReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const paymentStatus = urlParams.get('payment_status');
const sessionId = urlParams.get('session_id');
const paymentStatus = urlParams.get("payment_status");
const sessionId = urlParams.get("session_id");
if (paymentStatus === 'success' && sessionId) {
console.log('Payment successful via hosted checkout:', sessionId);
if (paymentStatus === "success" && sessionId) {
console.log("Payment successful via hosted checkout:", sessionId);
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
window.history.replaceState({}, "", window.location.pathname);
// Fetch current license info to determine upgrade vs new
let licenseInfo: LicenseInfo | null = null;
try {
licenseInfo = await licenseService.getLicenseInfo();
} catch (err) {
console.warn('Could not fetch license info:', err);
console.warn("Could not fetch license info:", err);
}
// Check if this is an upgrade or new subscription
// Only treat as upgrade if there's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
if (licenseInfo?.licenseType && licenseInfo.licenseType !== 'NORMAL') {
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
// UPGRADE: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license');
console.log("Upgrade detected - resyncing existing license");
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) {
@@ -123,9 +120,9 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
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);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success
@@ -135,24 +132,24 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.upgradeSuccess'),
alertType: "success",
title: t("payment.upgradeSuccess"),
});
}
} 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'),
alertType: "error",
title: t("payment.syncError"),
});
}
} else {
// NEW SUBSCRIPTION: Poll for license key
console.log('New subscription - polling for license key');
console.log("New subscription - polling for license key");
try {
const installationId = await licenseService.getInstallationId();
console.log('Polling for license key with installation ID:', installationId);
console.log("Polling for license key with installation ID:", installationId);
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installationId);
@@ -174,62 +171,62 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
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);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success with license key
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({
isUpgrade: false,
licenseKey: result.licenseKey
licenseKey: result.licenseKey,
});
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.licenseActivated'),
alertType: "success",
title: t("payment.licenseActivated"),
});
}
} else {
console.error('Failed to save license key:', activation.error);
console.error("Failed to save license key:", activation.error);
alert({
alertType: 'error',
title: t('payment.licenseSaveError'),
alertType: "error",
title: t("payment.licenseSaveError"),
});
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
console.warn("License key polling timed out");
alert({
alertType: 'warning',
title: t('payment.licenseDelayed'),
alertType: "warning",
title: t("payment.licenseDelayed"),
});
} else {
console.error('License key polling failed:', result.error);
console.error("License key polling failed:", result.error);
alert({
alertType: 'error',
title: t('payment.licensePollingError'),
alertType: "error",
title: t("payment.licensePollingError"),
});
}
} catch (error) {
console.error('Failed to poll for license key:', error);
console.error("Failed to poll for license key:", error);
alert({
alertType: 'error',
title: t('payment.licenseRetrievalError'),
alertType: "error",
title: t("payment.licenseRetrievalError"),
});
}
}
} else if (paymentStatus === 'canceled') {
console.log('Payment canceled by user');
} else if (paymentStatus === "canceled") {
console.log("Payment canceled by user");
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
window.history.replaceState({}, "", window.location.pathname);
alert({
alertType: 'warning',
title: t('payment.paymentCanceled'),
alertType: "warning",
title: t("payment.paymentCanceled"),
});
}
};
@@ -238,13 +235,13 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
}, [t, refetchPlans, refetchLicense, plans, fetchPlansIfNeeded, plansLoaded, currentCurrency]);
const openCheckout = useCallback(
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
async (tier: "server" | "enterprise", options: CheckoutOptions = {}) => {
try {
setIsLoading(true);
// Check if Supabase is configured
if (!isSupabaseConfigured) {
throw new Error('Checkout is not available. Supabase is not configured.');
throw new Error("Checkout is not available. Supabase is not configured.");
}
// Update currency if provided
@@ -265,27 +262,27 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
try {
const [licenseData, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers()
userManagementService.getUsers(),
]);
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
let calculatedMinSeats = options.minimumSeats || 1;
if (tier === 'enterprise' && !options.minimumSeats) {
if (tier === "enterprise" && !options.minimumSeats) {
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'server' || currentTier === 'free') {
if (currentTier === "server" || currentTier === "free") {
// Upgrading from Server (unlimited) to Enterprise (per-seat)
// Use current total user count as minimum
calculatedMinSeats = Math.max(totalUsers, 1);
console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`);
} else if (currentTier === 'enterprise') {
} 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);
@@ -295,7 +292,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
// Find the plan group for the requested tier
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
const planGroup = planGroups.find((pg) => pg.tier === tier);
if (!planGroup) {
throw new Error(`No ${tier} plan available`);
@@ -306,16 +303,15 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
setMinimumSeats(calculatedMinSeats);
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout';
console.error('Error opening checkout:', errorMessage);
const errorMessage = err instanceof Error ? err.message : "Failed to open checkout";
console.error("Error opening checkout:", errorMessage);
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded]
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded],
);
const closeCheckout = useCallback(() => {
@@ -331,30 +327,28 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
console.log("Payment successful, session:", sessionId);
currentOptions.onSuccess?.(sessionId);
// Don't close modal - let user view license key and close manually
},
[currentOptions]
[currentOptions],
);
const handlePaymentError = useCallback(
(error: string) => {
console.error('Payment error:', error);
console.error("Payment error:", error);
currentOptions.onError?.(error);
},
[currentOptions]
[currentOptions],
);
const handleLicenseActivated = useCallback((licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log('License activated:', licenseInfo);
// Could expose this via context if needed
}, []);
const handleLicenseActivated = useCallback(
(licenseInfo: { licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean }) => {
console.log("License activated:", licenseInfo);
// Could expose this via context if needed
},
[],
);
const contextValue: CheckoutContextValue = {
openCheckout,
@@ -387,7 +381,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
export const useCheckout = (): CheckoutContextValue => {
const context = useContext(CheckoutContext);
if (!context) {
throw new Error('useCheckout must be used within CheckoutProvider');
throw new Error("useCheckout must be used within CheckoutProvider");
}
return context;
};
@@ -1,8 +1,8 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from "react";
import { useLocation } from "react-router-dom";
import licenseService, { LicenseInfo } from "@app/services/licenseService";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { getSimulatedLicenseInfo } from "@app/testing/serverExperienceSimulations";
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
@@ -34,17 +34,17 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
// Wait for config to load if it's not available yet
let currentConfig = configRef.current;
if (!currentConfig) {
console.log('[LicenseContext] Config not loaded yet, waiting...');
console.log("[LicenseContext] Config not loaded yet, waiting...");
// OPTIMIZATION: Reduced from 5s to 1s - config should load quickly
const maxWait = 1000;
const startTime = Date.now();
while (!configRef.current && Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
currentConfig = configRef.current;
}
if (!currentConfig) {
console.error('[LicenseContext] Config failed to load after waiting');
console.error("[LicenseContext] Config failed to load after waiting");
setLoading(false);
return;
}
@@ -52,12 +52,12 @@ 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;
}
console.log('[LicenseContext] Fetching license info');
console.log("[LicenseContext] Fetching license info");
try {
const testInfo = getSimulatedLicenseInfo();
@@ -73,8 +73,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';
console.error('Error fetching license info:', errorMessage);
const errorMessage = err instanceof Error ? err.message : "Failed to fetch license info";
console.error("Error fetching license info:", errorMessage);
setError(errorMessage);
setLicenseInfo(null);
} finally {
@@ -88,13 +88,13 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
// during SAML/OAuth callback processing. License isn't needed until user
// is authenticated and navigates to main app.
const isAuthRoute =
location.pathname === '/login' ||
location.pathname === '/signup' ||
location.pathname === '/auth/callback' ||
location.pathname.startsWith('/invite/');
location.pathname === "/login" ||
location.pathname === "/signup" ||
location.pathname === "/auth/callback" ||
location.pathname.startsWith("/invite/");
if (isAuthRoute) {
console.log('[LicenseContext] On auth route, skipping license fetch');
console.log("[LicenseContext] On auth route, skipping license fetch");
setLoading(false);
return;
}
@@ -111,14 +111,10 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
error,
refetchLicense,
}),
[licenseInfo, loading, error, refetchLicense]
[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 => {
@@ -128,7 +124,7 @@ export const useOptionalLicense = (): LicenseContextValue | undefined => {
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
throw new Error('useLicense must be used within LicenseProvider');
throw new Error("useLicense must be used within LicenseProvider");
}
return context;
};
@@ -1,26 +1,15 @@
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 { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import apiClient from "@app/services/apiClient";
import { isAxiosError } from "axios";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { useLicense } from "@app/contexts/LicenseContext";
import { getSimulatedAdminUsage, getSimulatedWauResponse } from "@app/testing/serverExperienceSimulations";
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
const SELF_REPORTED_ADMIN_KEY = "stirling-self-reported-admin";
const FREE_TIER_LIMIT = 5;
type UserCountSource = 'admin' | 'estimate' | 'unknown';
type UserCountSource = "admin" | "estimate" | "unknown";
interface WeeklyActiveUsersResponse {
trackingSince: string;
@@ -39,16 +28,16 @@ interface UserCountState {
}
export type ServerScenarioKey =
| 'unknown'
| 'licensed'
| 'no-login-user-under-limit-no-license'
| 'no-login-admin-under-limit-no-license'
| 'no-login-user-over-limit-no-license'
| 'no-login-admin-over-limit-no-license'
| 'login-user-under-limit-no-license'
| 'login-admin-under-limit-no-license'
| 'login-user-over-limit-no-license'
| 'login-admin-over-limit-no-license';
| "unknown"
| "licensed"
| "no-login-user-under-limit-no-license"
| "no-login-admin-under-limit-no-license"
| "no-login-user-over-limit-no-license"
| "no-login-admin-over-limit-no-license"
| "login-user-under-limit-no-license"
| "login-admin-under-limit-no-license"
| "login-user-over-limit-no-license"
| "login-admin-over-limit-no-license";
export interface ServerExperienceValue {
loginEnabled: boolean;
@@ -82,27 +71,27 @@ export interface ServerExperienceValue {
const ServerExperienceContext = createContext<ServerExperienceValue | undefined>(undefined);
function getStoredSelfReportedAdmin(): boolean {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === "true";
} catch {
return false;
}
}
function getErrorMessage(error: unknown): string {
if (typeof error === '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) {
return error.message;
}
return 'Unable to load server usage';
return "Unable to load server usage";
}
export function ServerExperienceProvider({ children }: { children: ReactNode }) {
@@ -115,7 +104,7 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
source: "unknown",
lastUpdated: null,
error: null,
});
@@ -127,12 +116,12 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
try {
if (value) {
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, "true");
} else {
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
}
@@ -142,16 +131,16 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key === SELF_REPORTED_ADMIN_KEY) {
setSelfReportedAdminState(event.newValue === 'true');
setSelfReportedAdminState(event.newValue === "true");
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
useEffect(() => {
@@ -190,18 +179,16 @@ 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,
loading: false,
source: 'admin',
source: "admin",
lastUpdated: Date.now(),
error: null,
});
@@ -213,19 +200,16 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
const responseData =
testResponse ??
(
await apiClient.get<WeeklyActiveUsersResponse>('/api/v1/info/wau', {
await apiClient.get<WeeklyActiveUsersResponse>("/api/v1/info/wau", {
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,
loading: false,
source: 'estimate',
source: "estimate",
lastUpdated: Date.now(),
error: null,
});
@@ -235,7 +219,7 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
source: "unknown",
lastUpdated: null,
error: getErrorMessage(error),
});
@@ -251,7 +235,7 @@ 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(() => {
@@ -265,52 +249,37 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
}, [config?.premiumEnabled, licenseInfo]);
const overFreeTierLimit = useMemo(() => {
if (typeof userCountState.totalUsers !== 'number') {
if (typeof userCountState.totalUsers !== "number") {
return null;
}
return userCountState.totalUsers > FREE_TIER_LIMIT;
}, [userCountState.totalUsers]);
const userCountResolved =
!userCountState.loading && userCountState.source !== 'unknown' && userCountState.totalUsers !== null;
!userCountState.loading && userCountState.source !== "unknown" && userCountState.totalUsers !== null;
const scenarioKey = useMemo<ServerScenarioKey>(() => {
if (hasPaidLicense) {
return 'licensed';
return "licensed";
}
if (!userCountResolved || typeof userCountState.totalUsers !== 'number') {
return 'unknown';
if (!userCountResolved || typeof userCountState.totalUsers !== "number") {
return "unknown";
}
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
if (!loginEnabled) {
if (selfReportedAdmin) {
return overLimit
? 'no-login-admin-over-limit-no-license'
: 'no-login-admin-under-limit-no-license';
return overLimit ? "no-login-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,
@@ -341,17 +310,13 @@ 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,12 +1,12 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import licenseService, {} from '@app/services/licenseService';
import UpdateSeatsModal from '@app/components/shared/UpdateSeatsModal';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { useOptionalLicense } from '@app/contexts/LicenseContext';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import UpdateSeatsModal from "@app/components/shared/UpdateSeatsModal";
import { userManagementService } from "@app/services/userManagementService";
import { alert } from "@app/components/toast";
import { useOptionalLicense } from "@app/contexts/LicenseContext";
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
export interface UpdateSeatsOptions {
onSuccess?: () => void;
@@ -43,36 +43,36 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
// during SAML/OAuth callback. This check only matters after successful billing
// portal redirects, which never happen on auth routes.
const isAuthRoute =
location.pathname === '/login' ||
location.pathname === '/signup' ||
location.pathname === '/auth/callback' ||
location.pathname.startsWith('/invite/');
location.pathname === "/login" ||
location.pathname === "/signup" ||
location.pathname === "/auth/callback" ||
location.pathname.startsWith("/invite/");
if (isAuthRoute) {
console.log('[UpdateSeatsContext] On auth route, skipping billing return check');
console.log("[UpdateSeatsContext] On auth route, skipping billing return check");
return;
}
const handleBillingReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const seatsUpdated = urlParams.get('seats_updated');
const seatsUpdated = urlParams.get("seats_updated");
if (seatsUpdated === 'true') {
console.log('Seats updated successfully, syncing license with Keygen');
if (seatsUpdated === "true") {
console.log("Seats updated successfully, syncing license with Keygen");
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
window.history.replaceState({}, "", window.location.pathname);
try {
// Wait a moment for Stripe webhook to process
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Resync license with Keygen (not just local fetch)
console.log('Seat update detected - resyncing license with Keygen');
console.log("Seat update detected - resyncing license with Keygen");
const activation = await resyncExistingLicense();
if (activation.success) {
console.log('License synced successfully after seat update');
console.log("License synced successfully after seat update");
// Refresh global license context
await license?.refetchLicense();
@@ -81,25 +81,23 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
const updatedLicense = await licenseService.getLicenseInfo();
alert({
alertType: 'success',
title: t('billing.seatsUpdated', 'Seats Updated'),
body: t(
'billing.seatsUpdatedMessage',
'Your enterprise seats have been updated to {{seats}}',
{ seats: updatedLicense.maxUsers }
),
alertType: "success",
title: t("billing.seatsUpdated", "Seats Updated"),
body: t("billing.seatsUpdatedMessage", "Your enterprise seats have been updated to {{seats}}", {
seats: updatedLicense.maxUsers,
}),
});
} else {
throw new Error(activation.error || 'Failed to sync license');
throw new Error(activation.error || "Failed to sync license");
}
} catch (error) {
console.error('Failed to sync license after seat update:', error);
console.error("Failed to sync license after seat update:", error);
alert({
alertType: 'warning',
title: t('billing.updateProcessing', 'Update Processing'),
alertType: "warning",
title: t("billing.updateProcessing", "Update Processing"),
body: t(
'billing.updateProcessingMessage',
'Your seat update is being processed. Please refresh in a few moments.'
"billing.updateProcessingMessage",
"Your seat update is being processed. Please refresh in a few moments.",
),
});
}
@@ -108,56 +106,53 @@ 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]);
const openUpdateSeats = useCallback(async (options: UpdateSeatsOptions = {}) => {
try {
setIsLoading(true);
const openUpdateSeats = useCallback(
async (options: UpdateSeatsOptions = {}) => {
try {
setIsLoading(true);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers(),
]);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([licenseService.getLicenseInfo(), userManagementService.getUsers()]);
// Validate this is an enterprise license
if (!licenseInfo || licenseInfo.licenseType !== 'ENTERPRISE') {
throw new Error(
t('billing.notEnterprise', 'Seat management is only available for enterprise licenses')
// Validate this is an enterprise license
if (!licenseInfo || licenseInfo.licenseType !== "ENTERPRISE") {
throw new Error(t("billing.notEnterprise", "Seat management is only available for enterprise licenses"));
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
const currentUserCount = userData.totalUsers || 0;
// Minimum seats must be at least the current number of users
const calculatedMinSeats = Math.max(currentUserCount, 1);
console.log(
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`,
);
setCurrentSeats(currentLicenseSeats);
setMinimumSeats(calculatedMinSeats);
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to open seat update";
console.error("Error opening seat update:", errorMessage);
alert({
alertType: "error",
title: t("common.error", "Error"),
body: errorMessage,
});
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
const currentUserCount = userData.totalUsers || 0;
// Minimum seats must be at least the current number of users
const calculatedMinSeats = Math.max(currentUserCount, 1);
console.log(
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`
);
setCurrentSeats(currentLicenseSeats);
setMinimumSeats(calculatedMinSeats);
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to open seat update';
console.error('Error opening seat update:', errorMessage);
alert({
alertType: 'error',
title: t('common.error', 'Error'),
body: errorMessage,
});
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
}, [t]);
},
[t],
);
const closeUpdateSeats = useCallback(() => {
setIsOpen(false);
@@ -173,40 +168,36 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
// Get current license key
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo?.licenseKey) {
throw new Error('No license key found');
throw new Error("No license key found");
}
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
// Call manage-billing function with new seat count
const portalUrl = await licenseService.updateEnterpriseSeats(
newSeatCount,
licenseInfo.licenseKey
);
const portalUrl = await licenseService.updateEnterpriseSeats(newSeatCount, licenseInfo.licenseKey);
return portalUrl;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to update seats';
console.error('Error updating seats:', errorMessage);
const errorMessage = err instanceof Error ? err.message : "Failed to update seats";
console.error("Error updating seats:", errorMessage);
currentOptions.onError?.(errorMessage);
throw err;
}
},
[currentSeats, currentOptions]
[currentSeats, currentOptions],
);
const handleSuccess = useCallback(() => {
console.log('Seat update initiated successfully');
console.log("Seat update initiated successfully");
currentOptions.onSuccess?.();
}, [currentOptions]);
const handleError = useCallback(
(error: string) => {
console.error('Seat update error:', error);
console.error("Seat update error:", error);
currentOptions.onError?.(error);
},
[currentOptions]
[currentOptions],
);
return (
@@ -235,7 +226,7 @@ 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;
};
@@ -12,8 +12,8 @@ interface AccountLogoutDeps {
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
try {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1');
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
}
await signOut();
} finally {
+5 -8
View File
@@ -1,8 +1,5 @@
import { useState, useEffect } from 'react';
import licenseService, {
PlanTier,
PlansResponse,
} from '@app/services/licenseService';
import { useState, useEffect } from "react";
import licenseService, { PlanTier, PlansResponse } from "@app/services/licenseService";
export interface UsePlansReturn {
plans: PlanTier[];
@@ -11,7 +8,7 @@ export interface UsePlansReturn {
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -24,8 +21,8 @@ export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
console.error("Error fetching plans:", err);
setError(err instanceof Error ? err.message : "Failed to fetch plans");
} finally {
setLoading(false);
}
@@ -1,4 +1,4 @@
export function useRequestHeaders(): HeadersInit {
const token = localStorage.getItem('stirling_jwt');
return token ? { 'Authorization': `Bearer ${token}` } : {};
const token = localStorage.getItem("stirling_jwt");
return token ? { Authorization: `Bearer ${token}` } : {};
}
@@ -1,6 +1,5 @@
import { useServerExperienceContext } from '@app/contexts/ServerExperienceContext';
import { useServerExperienceContext } from "@app/contexts/ServerExperienceContext";
export function useServerExperience() {
return useServerExperienceContext();
}
@@ -1,6 +1,6 @@
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAuth } from '@app/auth/UseSession';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAuth } from "@app/auth/UseSession";
import { useIsMobile } from "@app/hooks/useIsMobile";
export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
@@ -9,9 +9,5 @@ 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;
}
@@ -1,10 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useState, useCallback, useEffect } from "react";
import { isAxiosError } from "axios";
import workflowService, {
WorkflowSessionResponse,
ParticipantResponse,
SignatureSubmissionRequest,
} from '@app/services/workflowService';
} from "@app/services/workflowService";
export interface UseParticipantSessionResult {
session: WorkflowSessionResponse | null;
@@ -38,8 +38,8 @@ export const useParticipantSession = (token?: string): UseParticipantSessionResu
setParticipant(participantData);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? (err.response?.data?.message || err.message)
: (err instanceof Error ? err.message : undefined) || 'Failed to load session';
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to load session";
setError(errorMsg);
} finally {
setLoading(false);
@@ -59,15 +59,15 @@ 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.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to submit signature";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession]
[loadSession],
);
const decline = useCallback(
@@ -75,48 +75,48 @@ 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.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to decline";
setError(errorMsg);
throw new Error(errorMsg, { cause: err });
} finally {
setLoading(false);
}
},
[loadSession]
[loadSession],
);
const downloadDocument = useCallback(async (token: string) => {
setLoading(true);
setError(null);
try {
const pdfBlob = await workflowService.getParticipantDocument(token);
const url = window.URL.createObjectURL(pdfBlob);
const a = document.createElement('a');
a.href = url;
a.download = session?.documentName || 'document.pdf';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? (err.response?.data?.message || err.message)
: (err instanceof Error ? err.message : undefined) || 'Failed to download document';
setError(errorMsg);
} finally {
setLoading(false);
}
}, [session]);
const downloadDocument = useCallback(
async (token: string) => {
setLoading(true);
setError(null);
try {
const pdfBlob = await workflowService.getParticipantDocument(token);
const url = window.URL.createObjectURL(pdfBlob);
const a = document.createElement("a");
a.href = url;
a.download = session?.documentName || "document.pdf";
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err: unknown) {
const errorMsg = isAxiosError(err)
? err.response?.data?.message || err.message
: (err instanceof Error ? err.message : undefined) || "Failed to download document";
setError(errorMsg);
} finally {
setLoading(false);
}
},
[session],
);
// Auto-load session if token is provided
useEffect(() => {
@@ -5,7 +5,7 @@
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 {
@@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import AuthCallback from '@app/routes/AuthCallback';
import { springAuth } from '@app/auth/springAuthClient';
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import AuthCallback from "@app/routes/AuthCallback";
import { springAuth } from "@app/auth/springAuthClient";
// Mock springAuth
vi.mock('@app/auth/springAuthClient', () => ({
vi.mock("@app/auth/springAuthClient", () => ({
springAuth: {
getSession: vi.fn(),
},
@@ -13,29 +13,29 @@ vi.mock('@app/auth/springAuthClient', () => ({
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe('AuthCallback', () => {
describe("AuthCallback", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Reset window.location.hash
window.location.hash = '';
window.location.hash = "";
});
it('should extract JWT from URL hash and validate it', async () => {
const mockToken = 'oauth-jwt-token';
it("should extract JWT from URL hash and validate it", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: '123',
email: '[email protected]',
username: 'oauthuser',
role: 'USER',
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
// Set URL hash with access token
@@ -54,103 +54,99 @@ describe('AuthCallback', () => {
error: null,
});
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
</BrowserRouter>,
);
await waitFor(() => {
// Verify JWT was stored
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
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();
// Verify navigation to home
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it('should redirect to login when no access token in hash', async () => {
it("should redirect to login when no access token in hash", async () => {
// No hash or empty hash
window.location.hash = '';
window.location.hash = "";
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: 'OAuth login failed - no token received.' },
state: { error: "OAuth login failed - no token received." },
});
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(localStorage.getItem("stirling_jwt")).toBeNull();
});
});
it('should redirect to login when token validation fails', async () => {
const invalidToken = 'invalid-oauth-token';
it("should redirect to login when token validation fails", async () => {
const invalidToken = "invalid-oauth-token";
window.location.hash = `#access_token=${invalidToken}`;
// Mock failed session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: { session: null },
error: { message: 'Invalid token' },
error: { message: "Invalid token" },
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
</BrowserRouter>,
);
await waitFor(() => {
// JWT should be stored initially
expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
expect(localStorage.getItem("stirling_jwt")).toBeNull(); // Cleared after validation failure
// Verify redirect to login
expect(mockNavigate).toHaveBeenCalledWith('/login', {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: 'OAuth login failed - invalid token.' },
state: { error: "OAuth login failed - invalid token." },
});
});
});
it('should handle errors gracefully', async () => {
const mockToken = 'error-token';
it("should handle errors gracefully", async () => {
const mockToken = "error-token";
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error('Network error')
);
vi.mocked(springAuth.getSession).mockRejectedValueOnce(new Error("Network error"));
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: 'OAuth login failed. Please try again.' },
state: { error: "OAuth login failed. Please try again." },
});
});
});
it('should display loading state while processing', () => {
window.location.hash = '#access_token=processing-token';
it("should display loading state while processing", () => {
window.location.hash = "#access_token=processing-token";
vi.mocked(springAuth.getSession).mockImplementationOnce(
() =>
@@ -159,19 +155,19 @@ describe('AuthCallback', () => {
() =>
resolve({
data: { session: null },
error: { message: 'Token expired' },
error: { message: "Token expired" },
}),
100
)
)
100,
),
),
);
const { getByText } = render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
</BrowserRouter>,
);
expect(getByText('Completing authentication')).toBeInTheDocument();
expect(getByText("Completing authentication")).toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
import styles from '@app/routes/AuthCallback.module.css';
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { springAuth } from "@app/auth/springAuthClient";
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
import styles from "@app/routes/AuthCallback.module.css";
/**
* OAuth Callback Handler
@@ -35,11 +35,11 @@ export default function AuthCallback() {
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') {
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', {
navigate("/login", {
replace: true,
state: { error: 'You have been signed out. Please sign in again.' }
state: { error: "You have been signed out. Please sign in again." },
});
return;
}
@@ -58,13 +58,13 @@ export default function AuthCallback() {
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
const token = params.get("access_token");
if (!token) {
console.error(`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`);
navigate('/login', {
navigate("/login", {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
state: { error: "OAuth login failed - no token received." },
});
return;
}
@@ -73,14 +73,16 @@ export default function AuthCallback() {
console.log(`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`);
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
localStorage.setItem("stirling_jwt", token);
console.log(`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`);
console.log(`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
window.dispatchEvent(new CustomEvent("jwt-available"));
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
console.log(`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`);
console.log(
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(`[AuthCallback:${executionId}] Step 4: Validating token with backend`);
// Validate the token and load user info
@@ -89,10 +91,10 @@ export default function AuthCallback() {
if (error || !data.session) {
console.error(`[AuthCallback:${executionId}] ❌ Failed to validate token:`, error);
localStorage.removeItem('stirling_jwt');
navigate('/login', {
localStorage.removeItem("stirling_jwt");
navigate("/login", {
replace: true,
state: { error: 'OAuth login failed - invalid token.' }
state: { error: "OAuth login failed - invalid token." },
});
return;
}
@@ -107,13 +109,15 @@ export default function AuthCallback() {
// Wait for all context providers to process jwt-available event
// This prevents infinite render loop when coming from cross-domain SAML redirect
await new Promise(resolve => setTimeout(resolve, 100));
console.log(`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`);
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(`[AuthCallback:${executionId}] Step 7: Navigating to home page`);
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
navigate("/", { replace: true });
const duration = performance.now() - startTime;
console.log(`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`);
@@ -128,9 +132,9 @@ export default function AuthCallback() {
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', {
navigate("/login", {
replace: true,
state: { error: 'OAuth login failed. Please try again.' }
state: { error: "OAuth login failed. Please try again." },
});
}
};
@@ -1,14 +1,14 @@
import { useState, useEffect } from 'react';
import { isAxiosError } from 'axios';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from '@mantine/core';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import { BASE_PATH } from '@app/constants/app';
import apiClient from '@app/services/apiClient';
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from "@mantine/core";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import { BASE_PATH } from "@app/constants/app";
import apiClient from "@app/services/apiClient";
interface InviteData {
email: string | null;
@@ -26,25 +26,25 @@ export default function InviteAccept() {
const [submitting, setSubmitting] = useState(false);
const [inviteData, setInviteData] = useState<InviteData | null>(null);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
title: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
description: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogTitle: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
ogDescription: t("app.description", "The Free Adobe Acrobat alternative (10M+ Downloads)"),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
useEffect(() => {
if (!token) {
setError(t('invite.invalidToken', 'Invalid invitation link'));
setError(t("invite.invalidToken", "Invalid invitation link"));
setLoading(false);
return;
}
@@ -62,9 +62,9 @@ export default function InviteAccept() {
setError(null);
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? (err.response?.data?.error || err.message)
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) ||
t('invite.validationError', 'Failed to validate invitation link');
t("invite.validationError", "Failed to validate invitation link");
setError(errorMessage);
} finally {
setLoading(false);
@@ -77,23 +77,23 @@ export default function InviteAccept() {
// Validate email if required
if (inviteData?.emailRequired) {
if (!email || email.trim().length === 0) {
setError(t('invite.emailRequired', 'Email address is required'));
setError(t("invite.emailRequired", "Email address is required"));
return;
}
if (!email.includes('@')) {
setError(t('invite.invalidEmail', 'Invalid email address'));
if (!email.includes("@")) {
setError(t("invite.invalidEmail", "Invalid email address"));
return;
}
}
// Validate passwords
if (!password) {
setError(t('invite.passwordRequired', 'Password is required'));
setError(t("invite.passwordRequired", "Password is required"));
return;
}
if (password !== confirmPassword) {
setError(t('invite.passwordMismatch', 'Passwords do not match'));
setError(t("invite.passwordMismatch", "Passwords do not match"));
return;
}
@@ -103,21 +103,20 @@ export default function InviteAccept() {
const formData = new FormData();
if (inviteData?.emailRequired) {
formData.append('email', email.trim().toLowerCase());
formData.append("email", email.trim().toLowerCase());
}
formData.append('password', password);
formData.append("password", password);
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
suppressErrorToast: true,
});
// Success - redirect to login
navigate('/login?messageType=accountCreated');
navigate("/login?messageType=accountCreated");
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? (err.response?.data?.error || err.message)
: (err instanceof Error ? err.message : undefined) ||
t('invite.acceptError', 'Failed to create account');
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) || t("invite.acceptError", "Failed to create account");
setError(errorMessage);
} finally {
setSubmitting(false);
@@ -127,7 +126,7 @@ 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>
@@ -138,15 +137,15 @@ 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
type="button"
onClick={() => navigate('/login')}
onClick={() => navigate("/login")}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t('invite.goToLogin', 'Go to Login')}
{t("invite.goToLogin", "Go to Login")}
</button>
</div>
</AuthLayout>
@@ -156,21 +155,22 @@ export default function InviteAccept() {
return (
<AuthLayout>
<LoginHeader
title={t('invite.welcomeTitle', "You've been invited!")}
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
title={t("invite.welcomeTitle", "You've been invited!")}
subtitle={t("invite.welcomeSubtitle", "Complete your account setup to get started")}
/>
{inviteData && !inviteData.emailRequired && (
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="xs" align="center">
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
{t('invite.accountFor', 'Creating account for')}
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: "0.05em" }}>
{t("invite.accountFor", "Creating account for")}
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</Text>
<Text size="xs" c="dimmed">
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
{t("invite.linkExpires", "Link expires")}: {new Date(inviteData.expiresAt).toLocaleDateString()} at{" "}
{new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
</Paper>
@@ -182,11 +182,11 @@ export default function InviteAccept() {
<Stack gap="md">
{inviteData?.emailRequired && (
<TextInput
label={t('invite.email', 'Email address')}
label={t("invite.email", "Email address")}
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"
@@ -194,20 +194,20 @@ export default function InviteAccept() {
)}
<PasswordInput
label={t('invite.choosePassword', 'Choose a password')}
label={t("invite.choosePassword", "Choose a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
placeholder={t("invite.passwordPlaceholder", "Enter your password")}
disabled={submitting}
required
autoComplete="new-password"
/>
<PasswordInput
label={t('invite.confirmPassword', 'Confirm password')}
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 +219,7 @@ 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>
@@ -227,9 +227,9 @@ 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">
{t('invite.signIn', 'Sign in')}
{t("invite.alreadyHaveAccount", "Already have an account?")}{" "}
<Anchor component="button" type="button" onClick={() => navigate("/login")} c="dark">
{t("invite.signIn", "Sign in")}
</Anchor>
</Text>
</Center>
+38 -44
View File
@@ -1,12 +1,12 @@
import { useEffect } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@app/auth/UseSession'
import { useAppConfig } from '@app/contexts/AppConfigContext'
import HomePage from '@app/pages/HomePage'
import { useBackendProbe } from '@app/hooks/useBackendProbe'
import AuthLayout from '@app/routes/authShared/AuthLayout'
import LoginHeader from '@app/routes/login/LoginHeader'
import { useTranslation } from 'react-i18next'
import { useEffect } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import HomePage from "@app/pages/HomePage";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import { useTranslation } from "react-i18next";
/**
* Landing component - Smart router based on authentication status
@@ -42,15 +42,15 @@ export default function Landing() {
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
if (backendProbe.status === 'up' || backendProbe.loginDisabled) {
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
return;
}
const tick = async () => {
const result = await backendProbe.probe();
if (result.status === 'up') {
if (result.status === "up") {
await refetch();
if (result.loginDisabled) {
navigate('/', { replace: true });
navigate("/", { replace: true });
}
}
};
@@ -61,13 +61,13 @@ export default function Landing() {
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]);
useEffect(() => {
if (backendProbe.status === 'up') {
if (backendProbe.status === "up") {
void refetch();
}
}, [backendProbe.status, refetch]);
console.log('[Landing] ════════════════════════════════════');
console.log('[Landing] Render state:', {
console.log("[Landing] ════════════════════════════════════");
console.log("[Landing] Render state:", {
pathname: location.pathname,
loading,
authLoading,
@@ -79,17 +79,15 @@ export default function Landing() {
backendStatus: backendProbe.status,
timestamp: new Date().toISOString(),
});
console.log('[Landing] ════════════════════════════════════');
console.log("[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>
<div className="text-gray-600">Loading...</div>
</div>
</div>
);
@@ -97,20 +95,18 @@ export default function Landing() {
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false || backendProbe.loginDisabled) {
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return (
<HomePage />
);
console.debug("[Landing] Login disabled - showing app in anonymous mode");
return <HomePage />;
}
// If backend is not up yet and user is not authenticated, show a branded status screen
if (!session && backendProbe.status !== 'up') {
const backendTitle = t('backendStartup.notFoundTitle', 'Backend not found');
if (!session && backendProbe.status !== "up") {
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
const handleRetry = async () => {
const result = await backendProbe.probe();
if (result.status === 'up') {
if (result.status === "up") {
await refetch();
navigate('/', { replace: true });
navigate("/", { replace: true });
}
};
return (
@@ -119,23 +115,21 @@ export default function Landing() {
<div
className="auth-section"
style={{
padding: '1.5rem',
marginTop: '1rem',
borderRadius: '0.75rem',
backgroundColor: 'rgba(37, 99, 235, 0.08)',
border: '1px solid rgba(37, 99, 235, 0.2)',
padding: "1.5rem",
marginTop: "1rem",
borderRadius: "0.75rem",
backgroundColor: "rgba(37, 99, 235, 0.08)",
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}
className="auth-cta-button px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mt-5 border-0 cursor-pointer"
style={{ width: 'fit-content' }}
style={{ width: "fit-content" }}
>
{t('backendStartup.retry', 'Retry')}
{t("backendStartup.retry", "Retry")}
</button>
</div>
</AuthLayout>
@@ -145,14 +139,14 @@ export default function Landing() {
// If we have a session, show the main app
// Note: First login password change is now handled by the onboarding flow
if (session) {
return (
<HomePage />
);
return <HomePage />;
}
// No session - redirect to login page
// This ensures the URL always shows /login when not authenticated
return (config?.enableLogin === true && !backendProbe.loginDisabled)
? <Navigate to="/login" replace state={{ from: location }} />
: <HomePage />;
return config?.enableLogin === true && !backendProbe.loginDisabled ? (
<Navigate to="/login" replace state={{ from: location }} />
) : (
<HomePage />
);
}
+210 -179
View File
@@ -1,47 +1,47 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
import apiClient from '@app/services/apiClient';
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import Login from "@app/routes/Login";
import { useAuth } from "@app/auth/UseSession";
import { springAuth } from "@app/auth/springAuthClient";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import apiClient from "@app/services/apiClient";
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') return fallback;
if (typeof fallback === "string") return fallback;
return key;
},
}),
initReactI18next: {
type: '3rdParty',
type: "3rdParty",
init: vi.fn(),
},
}));
// Mock i18n module to avoid initialization
vi.mock('@app/i18n', () => ({
vi.mock("@app/i18n", () => ({
updateSupportedLanguages: vi.fn(),
supportedLanguages: { 'en-GB': 'English' },
supportedLanguages: { "en-GB": "English" },
rtlLanguages: [],
default: {
language: 'en-GB',
language: "en-GB",
changeLanguage: vi.fn(),
options: {},
},
}));
// Mock useAuth hook
vi.mock('@app/auth/UseSession', () => ({
vi.mock("@app/auth/UseSession", () => ({
useAuth: vi.fn(),
}));
// Mock springAuth
vi.mock('@app/auth/springAuthClient', () => ({
vi.mock("@app/auth/springAuthClient", () => ({
springAuth: {
signInWithPassword: vi.fn(),
signInWithOAuth: vi.fn(),
@@ -49,12 +49,12 @@ vi.mock('@app/auth/springAuthClient', () => ({
}));
// Mock useDocumentMeta
vi.mock('@app/hooks/useDocumentMeta', () => ({
vi.mock("@app/hooks/useDocumentMeta", () => ({
useDocumentMeta: vi.fn(),
}));
// Mock apiClient for provider list
vi.mock('@app/services/apiClient', () => ({
vi.mock("@app/services/apiClient", () => ({
default: {
get: vi.fn(),
post: vi.fn(),
@@ -63,21 +63,21 @@ vi.mock('@app/services/apiClient', () => ({
const mockNavigate = vi.fn();
const mockBackendProbeState = {
status: 'up' as const,
status: "up" as const,
loginDisabled: false,
loading: false,
};
const mockProbe = vi.fn().mockResolvedValue(mockBackendProbeState);
vi.mock('@app/hooks/useBackendProbe', () => ({
vi.mock("@app/hooks/useBackendProbe", () => ({
useBackendProbe: () => ({
...mockBackendProbeState,
probe: mockProbe,
}),
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
@@ -87,16 +87,14 @@ vi.mock('react-router-dom', async () => {
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>
<PreferencesProvider>
{children}
</PreferencesProvider>
<PreferencesProvider>{children}</PreferencesProvider>
</MantineProvider>
);
describe('Login', () => {
describe("Login", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBackendProbeState.status = 'up';
mockBackendProbeState.status = "up";
mockBackendProbeState.loginDisabled = false;
mockBackendProbeState.loading = false;
mockProbe.mockResolvedValue(mockBackendProbeState);
@@ -120,31 +118,31 @@ describe('Login', () => {
});
});
it('should render login form', async () => {
it("should render login form", async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
// Check for login form elements - use id since it's more reliable
const emailInput = document.getElementById('email');
const emailInput = document.getElementById("email");
expect(emailInput).toBeTruthy();
});
});
it('should redirect authenticated user to home', async () => {
it("should redirect authenticated user to home", async () => {
const mockSession = {
user: {
id: '123',
email: '[email protected]',
username: 'testuser',
role: 'USER',
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
},
access_token: 'mock-token',
access_token: "mock-token",
expires_in: 3600,
};
@@ -162,15 +160,15 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it('should show loading state while auth is loading', () => {
it("should show loading state while auth is loading", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
@@ -185,25 +183,25 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
// Component shouldn't redirect or show form while loading
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should handle email/password login', async () => {
it("should handle email/password login", async () => {
const user = userEvent.setup();
const mockUser = {
id: '123',
email: '[email protected]',
username: '[email protected]',
role: 'USER',
id: "123",
email: "[email protected]",
username: "[email protected]",
role: "USER",
};
const mockSession = {
user: mockUser,
access_token: 'new-token',
access_token: "new-token",
expires_in: 3600,
};
@@ -218,49 +216,55 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
// Wait for form to load
await waitFor(() => {
const emailInput = document.getElementById('email');
expect(emailInput).toBeTruthy();
const passwordInput = document.getElementById('password');
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const emailInput = document.getElementById("email");
expect(emailInput).toBeTruthy();
const passwordInput = document.getElementById("password");
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
// Fill in form using getElementById
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
if (!emailInput || !passwordInput) {
throw new Error('Form inputs not found');
throw new Error("Form inputs not found");
}
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password123');
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password123");
// Submit form - use a more flexible query
// Look for button with type="submit" in the form
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
await waitFor(() => {
expect(springAuth.signInWithPassword).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
email: "[email protected]",
password: "password123",
});
});
});
it('should use actual provider ID for OAuth login (authentik)', async () => {
it("should use actual provider ID for OAuth login (authentik)", async () => {
const user = userEvent.setup();
// Mock provider list with authentik
@@ -268,7 +272,7 @@ describe('Login', () => {
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/authentik': 'Authentik',
"/oauth2/authorization/authentik": "Authentik",
},
},
});
@@ -282,28 +286,31 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
// Wait for OAuth button to appear
await waitFor(() => {
const button = screen.queryByText('Authentik');
expect(button).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const button = screen.queryByText("Authentik");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText('Authentik');
const oauthButton = screen.getByText("Authentik");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path directly, NOT map to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: '/oauth2/authorization/authentik',
options: { redirectTo: '/auth/callback' }
provider: "/oauth2/authorization/authentik",
options: { redirectTo: "/auth/callback" },
});
});
});
it('should use actual provider ID for OAuth login (custom provider)', async () => {
it("should use actual provider ID for OAuth login (custom provider)", async () => {
const user = userEvent.setup();
// Mock provider list with custom provider 'mycompany'
@@ -311,7 +318,7 @@ describe('Login', () => {
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/mycompany': 'My Company SSO',
"/oauth2/authorization/mycompany": "My Company SSO",
},
},
});
@@ -325,29 +332,32 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
// Wait for OAuth button to appear (will show 'Mycompany' as label)
await waitFor(() => {
const button = screen.queryByText('Mycompany');
expect(button).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const button = screen.queryByText("Mycompany");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText('Mycompany');
const oauthButton = screen.getByText("Mycompany");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path directly - this is the critical fix
// Previously it would map unknown providers to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: '/oauth2/authorization/mycompany',
options: { redirectTo: '/auth/callback' }
provider: "/oauth2/authorization/mycompany",
options: { redirectTo: "/auth/callback" },
});
});
});
it('should use oidc provider ID when explicitly configured', async () => {
it("should use oidc provider ID when explicitly configured", async () => {
const user = userEvent.setup();
// Mock provider list with 'oidc'
@@ -355,7 +365,7 @@ describe('Login', () => {
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/oidc': 'OIDC',
"/oauth2/authorization/oidc": "OIDC",
},
},
});
@@ -369,30 +379,33 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
// Wait for OAuth button to appear
await waitFor(() => {
const button = screen.queryByText('OIDC');
expect(button).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const button = screen.queryByText("OIDC");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText('OIDC');
const oauthButton = screen.getByText("OIDC");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path when explicitly configured
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: '/oauth2/authorization/oidc',
options: { redirectTo: '/auth/callback' }
provider: "/oauth2/authorization/oidc",
options: { redirectTo: "/auth/callback" },
});
});
});
it('should show error on failed login', async () => {
it("should show error on failed login", async () => {
const user = userEvent.setup();
const errorMessage = 'Invalid credentials';
const errorMessage = "Invalid credentials";
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
user: null,
@@ -405,30 +418,36 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const emailInput = document.getElementById("email");
const passwordInput = document.getElementById("password");
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'wrongpassword');
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "wrongpassword");
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
await waitFor(() => {
@@ -436,28 +455,34 @@ describe('Login', () => {
});
});
it('should validate empty email and password', async () => {
it("should validate empty email and password", async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
expect(document.getElementById('email')).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
expect(document.getElementById("email")).toBeTruthy();
},
{ timeout: 3000 },
);
// Find the submit button
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
// Button should be disabled when email/password are empty
expect(submitButton).toBeDisabled();
@@ -466,50 +491,50 @@ describe('Login', () => {
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
});
it('should display session expired message from URL param', () => {
it("should display session expired message from URL param", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={['/login?expired=true']}>
<MemoryRouter initialEntries={["/login?expired=true"]}>
<Login />
</MemoryRouter>
</TestWrapper>
</TestWrapper>,
);
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
});
it('should display account created success message', () => {
it("should display account created success message", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={['/login?messageType=accountCreated']}>
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
<Login />
</MemoryRouter>
</TestWrapper>
</TestWrapper>,
);
expect(screen.getByText(/account created/i)).toBeInTheDocument();
});
it('should prefill email from query param', () => {
const email = '[email protected]';
it("should prefill email from query param", () => {
const email = "[email protected]";
render(
<TestWrapper>
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
<Login />
</MemoryRouter>
</TestWrapper>
</TestWrapper>,
);
return waitFor(() => {
const emailInput = document.getElementById('email') as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
expect(emailInput.value).toBe(email);
});
});
it('should redirect to home when login disabled', async () => {
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,
@@ -522,20 +547,20 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it('should handle OAuth provider click', async () => {
it("should handle OAuth provider click", async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/github': 'GitHub',
"/oauth2/authorization/github": "GitHub",
},
},
});
@@ -549,7 +574,7 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
@@ -564,7 +589,7 @@ describe('Login', () => {
expect(springAuth.signInWithOAuth).toBeDefined();
});
it('should show email form by default when no SSO providers', async () => {
it("should show email form by default when no SSO providers", async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
@@ -577,16 +602,16 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
expect(document.getElementById('email')).toBeInTheDocument();
expect(document.getElementById('password')).toBeInTheDocument();
expect(document.getElementById("email")).toBeInTheDocument();
expect(document.getElementById("password")).toBeInTheDocument();
});
});
it('should disable submit button while signing in', async () => {
it("should disable submit button while signing in", async () => {
const user = userEvent.setup();
vi.mocked(springAuth.signInWithPassword).mockImplementationOnce(
@@ -597,11 +622,11 @@ describe('Login', () => {
resolve({
user: null,
session: null,
error: { message: 'Error' },
error: { message: "Error" },
}),
100
)
)
100,
),
),
);
render(
@@ -609,30 +634,36 @@ describe('Login', () => {
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
</TestWrapper>,
);
await waitFor(() => {
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
await waitFor(
() => {
const emailInput = document.getElementById("email");
const passwordInput = document.getElementById("password");
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById("password") as HTMLInputElement;
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password123');
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password123");
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
// Button should be disabled while signing in
+145 -136
View File
@@ -1,25 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import { useBackendProbe } from '@app/hooks/useBackendProbe';
import apiClient from '@app/services/apiClient';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { updateSupportedLanguages } from '@app/i18n';
import { useEffect, useMemo, useRef, useState } from "react";
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";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useTranslation } from "react-i18next";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import apiClient from "@app/services/apiClient";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { updateSupportedLanguages } from "@app/i18n";
// Import login components
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 DividerWithText from '@app/components/shared/DividerWithText';
import LoggedInState from '@app/routes/login/LoggedInState';
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 DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
export default function Login() {
const navigate = useNavigate();
@@ -32,14 +32,14 @@ export default function Login() {
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(() => searchParams.get('email') ?? '');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [email, setEmail] = useState(() => searchParams.get("email") ?? "");
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const [loginMethod, setLoginMethod] = useState<string>('all');
const [loginMethod, setLoginMethod] = useState<string>("all");
const [ssoAutoLogin, setSsoAutoLogin] = useState(false);
const backendProbe = useBackendProbe();
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
@@ -47,18 +47,18 @@ export default function Login() {
const loginDisabled = backendProbe.loginDisabled === true || _enableLogin === false;
const autoLoginAttempted = useRef(false);
const autoLoginErrorRecorded = useRef(false);
const isUserPassAllowed = loginMethod === 'all' || loginMethod === 'normal';
const isSsoOnlyMode = loginMethod !== 'all' && loginMethod !== 'normal';
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal";
const isSingleSsoOnly = !isUserPassAllowed && enabledProviders.length === 1;
const AUTO_LOGIN_ATTEMPTS_KEY = 'stirling_sso_auto_login_attempts';
const AUTO_LOGIN_ERRORS_KEY = 'stirling_sso_auto_login_errors';
const AUTO_LOGIN_LOGOUT_KEY = 'stirling_sso_auto_login_logged_out';
const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts";
const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors";
const AUTO_LOGIN_LOGOUT_KEY = "stirling_sso_auto_login_logged_out";
const MAX_AUTO_LOGIN_ATTEMPTS = 2;
const MAX_AUTO_LOGIN_ERRORS = 1;
const readSessionNumber = (key: string) => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return 0;
}
const raw = window.sessionStorage.getItem(key);
@@ -67,21 +67,21 @@ export default function Login() {
};
const writeSessionNumber = (key: string, value: number) => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
window.sessionStorage.setItem(key, String(value));
};
const hasLogoutBlock = () => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return false;
}
return window.sessionStorage.getItem(AUTO_LOGIN_LOGOUT_KEY) === '1';
return window.sessionStorage.getItem(AUTO_LOGIN_LOGOUT_KEY) === "1";
};
const clearLogoutBlock = () => {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return;
}
window.sessionStorage.removeItem(AUTO_LOGIN_LOGOUT_KEY);
@@ -102,7 +102,15 @@ export default function Login() {
if (!searchParams) {
return null;
}
const errorParamKeys = ['error', 'error_description', 'error_code', 'sso_error', 'oauth_error', 'saml_error', 'login_error'];
const errorParamKeys = [
"error",
"error_description",
"error_code",
"sso_error",
"oauth_error",
"saml_error",
"login_error",
];
for (const key of errorParamKeys) {
const value = searchParams.get(key);
if (value) {
@@ -110,8 +118,8 @@ export default function Login() {
}
}
for (const [key, value] of searchParams.entries()) {
if (key.toLowerCase().includes('error')) {
return value || 'Single sign-on failed. Please try again.';
if (key.toLowerCase().includes("error")) {
return value || "Single sign-on failed. Please try again.";
}
}
return null;
@@ -121,15 +129,15 @@ export default function Login() {
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
if (backendProbe.status === 'up' || backendProbe.loginDisabled) {
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
return;
}
const tick = async () => {
const result = await backendProbe.probe();
if (result.status === 'up') {
if (result.status === "up") {
await refetch();
if (loginDisabled) {
navigate('/', { replace: true });
navigate("/", { replace: true });
}
}
};
@@ -143,8 +151,8 @@ export default function Login() {
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 });
navigate(returnPath || '/', { replace: true });
console.debug("[Login] User already authenticated, redirecting to home", { returnPath });
navigate(returnPath || "/", { replace: true });
}
}, [session, loading, navigate, location.state]);
@@ -152,13 +160,13 @@ export default function Login() {
useEffect(() => {
if (backendProbe.loginDisabled) {
// Slight delay to allow state updates before redirecting
const id = setTimeout(() => navigate('/', { replace: true }), 0);
const id = setTimeout(() => navigate("/", { replace: true }), 0);
return () => clearTimeout(id);
}
}, [backendProbe.loginDisabled, navigate]);
useEffect(() => {
if (backendProbe.status === 'up') {
if (backendProbe.status === "up") {
void refetch();
}
}, [backendProbe.status, refetch]);
@@ -167,13 +175,13 @@ 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
if (data.enableLogin === false) {
console.debug('[Login] Login disabled, redirecting to home');
navigate('/');
console.debug("[Login] Login disabled, redirecting to home");
navigate("/");
return;
}
@@ -195,18 +203,18 @@ export default function Login() {
const providerPaths = Object.keys(data.providerList || {});
setEnabledProviders(providerPaths);
setLoginMethod(data.loginMethod || 'all');
setLoginMethod(data.loginMethod || "all");
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
console.error("[Login] Failed to fetch enabled providers:", err);
// Set default values on error to ensure UI remains functional
// Login method defaults to 'all' to show both SSO and email/password options
setEnableLogin(true);
setLoginMethod('all');
setLoginMethod("all");
setEnabledProviders([]);
}
};
if (backendProbe.status === 'up' || backendProbe.loginDisabled) {
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
fetchProviders();
}
}, [navigate, backendProbe.status, backendProbe.loginDisabled]);
@@ -214,13 +222,11 @@ 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
const isUserPassAllowed = loginMethod === 'all' || loginMethod === 'normal';
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
// Show email form if no SSO providers exist AND username/password is allowed
if (!hasProviders && isUserPassAllowed) {
@@ -243,16 +249,19 @@ export default function Login() {
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
const { error } = await springAuth.signInWithOAuth({
provider: provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
options: { redirectTo: `${BASE_PATH}/auth/callback` },
});
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');
setError(
t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }) ||
"An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
}
@@ -270,7 +279,7 @@ 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;
}
@@ -304,29 +313,31 @@ export default function Login() {
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
try {
const emailFromQuery = searchParams.get('email');
const emailFromQuery = searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
}
// 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.'));
const expired = searchParams.get("expired");
if (expired === "true") {
setError(t("login.sessionExpired", "Your session has expired. Please sign in again."));
}
const messageType = searchParams.get('messageType')
const messageType = searchParams.get("messageType");
if (messageType) {
switch (messageType) {
case 'accountCreated':
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.'))
break
case 'credsUpdated':
setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.'))
break
case "accountCreated":
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."),
);
break;
case "credsUpdated":
setSuccessMessage(t("login.credentialsUpdated", "Your credentials have been updated. Please sign in again."));
break;
}
}
@@ -349,12 +360,12 @@ 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)'),
ogTitle: `${t('login.title', 'Sign in')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
title: `${t("login.title", "Sign in")} - Stirling PDF`,
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)"),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// If login is disabled, short-circuit to home (avoids rendering the form after retry)
@@ -368,13 +379,13 @@ export default function Login() {
}
// If backend isn't ready yet, show a lightweight status screen instead of the form
if (backendProbe.status !== 'up' && !loginDisabled) {
const backendTitle = t('backendStartup.notFoundTitle', 'Backend not found');
if (backendProbe.status !== "up" && !loginDisabled) {
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
const handleRetry = async () => {
const result = await backendProbe.probe();
if (result.status === 'up') {
if (result.status === "up") {
await refetch();
navigate('/', { replace: true });
navigate("/", { replace: true });
}
};
return (
@@ -383,23 +394,21 @@ export default function Login() {
<div
className="auth-section"
style={{
padding: '1.5rem',
marginTop: '1rem',
borderRadius: '0.75rem',
backgroundColor: 'rgba(37, 99, 235, 0.08)',
border: '1px solid rgba(37, 99, 235, 0.2)',
padding: "1.5rem",
marginTop: "1rem",
borderRadius: "0.75rem",
backgroundColor: "rgba(37, 99, 235, 0.08)",
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}
className="auth-cta-button px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mt-5 border-0 cursor-pointer"
style={{ width: 'fit-content' }}
style={{ width: "fit-content" }}
>
{t('backendStartup.retry', 'Retry')}
{t("backendStartup.retry", "Retry")}
</button>
</div>
</AuthLayout>
@@ -408,12 +417,12 @@ 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;
}
if (requiresMfa && !mfaCode.trim()) {
setError(t('login.mfaRequired', 'Two-factor code required'));
setError(t("login.mfaRequired", "Two-factor code required"));
return;
}
@@ -422,7 +431,7 @@ export default function Login() {
setError(null);
clearLogoutBlock();
console.log('[Login] Signing in with email:', email);
console.log("[Login] Signing in with email:", email);
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
@@ -431,22 +440,25 @@ export default function Login() {
});
if (error) {
console.error('[Login] Email sign in error:', error);
console.error("[Login] Email sign in error:", error);
setError(error.message);
if (error.mfaRequired || error.code === 'invalid_mfa_code') {
if (error.mfaRequired || error.code === "invalid_mfa_code") {
setRequiresMfa(true);
}
} else if (user && session) {
console.log('[Login] Email sign in successful');
console.log("[Login] Email sign in successful");
clearLogoutBlock();
setRequiresMfa(false);
setMfaCode('');
setMfaCode("");
// Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here
}
} catch (err) {
console.error('[Login] Unexpected error:', err);
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", { message: err instanceof Error ? err.message : "Unknown error" }) ||
"An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
}
@@ -459,24 +471,21 @@ 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 && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
border: '1px solid rgba(34, 197, 94, 0.3)',
borderRadius: '0.5rem',
color: '#16a34a'
}}>
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
{successMessage}
</p>
<div
style={{
padding: "1rem",
marginBottom: "1rem",
backgroundColor: "rgba(34, 197, 94, 0.1)",
border: "1px solid rgba(34, 197, 94, 0.3)",
borderRadius: "0.5rem",
color: "#16a34a",
}}
>
<p style={{ margin: 0, fontSize: "0.875rem", textAlign: "center" }}>{successMessage}</p>
</div>
)}
@@ -488,14 +497,14 @@ 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 */}
@@ -507,14 +516,14 @@ export default function Login() {
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t('login.useEmailInstead', 'Login with email')}
{t("login.useEmailInstead", "Login with email")}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked, but ONLY if username/password is allowed */}
{showEmailForm && isUserPassAllowed && (
<div style={{ marginTop: hasSSOProviders ? '1rem' : '0' }}>
<div style={{ marginTop: hasSSOProviders ? "1rem" : "0" }}>
<EmailPasswordForm
email={email}
password={password}
@@ -526,36 +535,36 @@ 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>
)}
{/* Help section - only show on first-time setup with default credentials and username/password auth allowed */}
{isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed && (
<Alert
color="blue"
variant="light"
radius="md"
mt="xl"
>
<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)' }}>
{t('login.defaultCredentials', 'Default Login Credentials')}
<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)' }}>{t('login.username', 'Username')}:</Text> admin
<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)' }}>{t('login.password', 'Password')}:</Text> stirling
<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>
)}
</AuthLayout>
);
}
@@ -1,21 +1,21 @@
import { useEffect, useMemo, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { useAuth } from '@app/auth/UseSession';
import { useFileActions } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { alert } from '@app/components/toast';
import type { StirlingFile } from '@app/types/fileContext';
import type { FileId } from '@app/types/file';
import { fileStorage } from '@app/services/fileStorage';
import { useEffect, useMemo, useRef } from "react";
import { isAxiosError } from "axios";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { useFileActions } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { alert } from "@app/components/toast";
import type { StirlingFile } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { fileStorage } from "@app/services/fileStorage";
import {
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from '@app/services/shareBundleUtils';
} from "@app/services/shareBundleUtils";
interface ShareLinkLoaderProps {
token: string;
@@ -60,7 +60,7 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
try {
const metadataResponse = await apiClient.get<ShareLinkMetadata>(
`/api/v1/storage/share-links/${normalizedToken}/metadata`,
{ suppressErrorToast: true, skipAuthRedirect: true, signal }
{ suppressErrorToast: true, skipAuthRedirect: true, signal },
);
shareMetadata = metadataResponse.data;
} catch {
@@ -68,21 +68,17 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
}
const response = await apiClient.get(`/api/v1/storage/share-links/${normalizedToken}`, {
responseType: 'blob',
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;
@@ -114,13 +110,9 @@ 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,
@@ -150,9 +142,7 @@ 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;
@@ -166,8 +156,8 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench('viewer');
navigate('/', { replace: true });
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
return;
}
}
@@ -183,62 +173,62 @@ export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
if (stirlingFiles.length > 0) {
const ids = stirlingFiles.map((stirlingFile: StirlingFile) => stirlingFile.fileId);
actions.setSelectedFiles(ids);
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);
await fileStorage.updateFileMetadata(fileId, sharedUpdates);
}
}
navActions.setWorkbench('viewer');
navigate('/', { replace: true });
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
} catch (error: unknown) {
if (signal.aborted) return;
const status = isAxiosError(error) ? error.response?.status : undefined;
if (status === 401 || status === 403) {
if (!isAuthenticated && !authLoading) {
alert({
alertType: 'warning',
title: t('storageShare.requiresLogin', 'This shared file requires login.'),
alertType: "warning",
title: t("storageShare.requiresLogin", "This shared file requires login."),
expandable: false,
durationMs: 4000,
});
navigate('/login', {
navigate("/login", {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
return;
}
alert({
alertType: 'warning',
alertType: "warning",
title: t(
'storageShare.accessDenied',
'You do not have access to this shared file. Ask the owner to share it with you.'
"storageShare.accessDenied",
"You do not have access to this shared file. Ask the owner to share it with you.",
),
expandable: false,
durationMs: 4500,
});
navigate('/', { replace: true });
navigate("/", { replace: true });
} else if (status === 404 || status === 410) {
alert({
alertType: 'error',
title: t('storageShare.expiredTitle', 'Link expired'),
alertType: "error",
title: t("storageShare.expiredTitle", "Link expired"),
expandable: false,
durationMs: 4000,
});
navigate('/', { replace: true });
navigate("/", { replace: true });
} else {
alert({
alertType: 'error',
title: t('storageShare.loadFailed', 'Unable to open shared file.'),
alertType: "error",
title: t("storageShare.loadFailed", "Unable to open shared file."),
expandable: false,
durationMs: 4000,
});
@@ -1,23 +1,23 @@
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 { useTranslation } from 'react-i18next';
import DownloadIcon from '@mui/icons-material/Download';
import LoginIcon from '@mui/icons-material/Login';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
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 { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import LoginIcon from "@mui/icons-material/Login";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useFileActions } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { alert } from '@app/components/toast';
import { useFileActions } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { alert } from "@app/components/toast";
import {
downloadShareLink,
fetchShareLinkMetadata,
importShareLinkToWorkbench,
ShareLinkMetadata,
} from '@app/services/shareLinkImport';
} 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 }>();
@@ -25,37 +25,36 @@ export default function ShareLinkPage() {
const { actions: navActions } = useNavigationActions();
const navigate = useNavigate();
const { t } = useTranslation();
const [status, setStatus] = useState<ShareLinkStatus>('loading');
const [status, setStatus] = useState<ShareLinkStatus>("loading");
const [metadata, setMetadata] = useState<ShareLinkMetadata | null>(null);
const [isWorking, setIsWorking] = useState(false);
const normalizedToken = useMemo(() => (token || '').trim(), [token]);
const shareRole = (metadata?.accessRole ?? 'viewer').toLowerCase();
const hasReadAccess =
shareRole === 'editor' || shareRole === 'commenter' || shareRole === 'viewer';
const normalizedToken = useMemo(() => (token || "").trim(), [token]);
const shareRole = (metadata?.accessRole ?? "viewer").toLowerCase();
const hasReadAccess = shareRole === "editor" || shareRole === "commenter" || shareRole === "viewer";
const canDownload = hasReadAccess;
const canOpen = hasReadAccess;
const loadMetadata = useCallback(async () => {
if (!normalizedToken) {
setStatus('notfound');
setStatus("notfound");
return;
}
setStatus('loading');
setStatus("loading");
try {
const data = await fetchShareLinkMetadata(normalizedToken);
setMetadata(data);
setStatus('ready');
setStatus("ready");
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
if (statusCode === 401) {
setStatus('login');
setStatus("login");
} else if (statusCode === 403) {
setStatus('forbidden');
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
setStatus("notfound");
} else {
setStatus('error');
setStatus("error");
}
}
}, [normalizedToken]);
@@ -65,7 +64,7 @@ export default function ShareLinkPage() {
}, [loadMetadata]);
const handleLogin = useCallback(() => {
navigate('/login', {
navigate("/login", {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
@@ -77,9 +76,9 @@ export default function ShareLinkPage() {
try {
const { blob, filename } = await downloadShareLink(normalizedToken);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = filename || 'shared-file';
link.download = filename || "shared-file";
document.body.appendChild(link);
link.click();
link.remove();
@@ -87,15 +86,15 @@ export default function ShareLinkPage() {
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
if (statusCode === 401) {
setStatus('login');
setStatus("login");
} else if (statusCode === 403) {
setStatus('forbidden');
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
setStatus("notfound");
} else {
alert({
alertType: 'error',
title: t('storageShare.downloadFailed', 'Unable to download this file.'),
alertType: "error",
title: t("storageShare.downloadFailed", "Unable to download this file."),
expandable: false,
durationMs: 3500,
});
@@ -109,28 +108,24 @@ 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 });
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
} catch (error: unknown) {
const statusCode = isAxiosError(error) ? error.response?.status : undefined;
if (statusCode === 401) {
setStatus('login');
setStatus("login");
} else if (statusCode === 403) {
setStatus('forbidden');
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus('notfound');
setStatus("notfound");
} else {
alert({
alertType: 'error',
title: t('storageShare.loadFailed', 'Unable to open shared file.'),
alertType: "error",
title: t("storageShare.loadFailed", "Unable to open shared file."),
expandable: false,
durationMs: 3500,
});
@@ -140,48 +135,48 @@ 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' }}>
<div style={{ minHeight: "100%", padding: "2.5rem 1.5rem" }}>
<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">
{shareRole === 'editor'
? t('storageShare.roleEditor', 'Editor')
: shareRole === 'commenter'
? t('storageShare.roleCommenter', 'Commenter')
: t('storageShare.roleViewer', 'Viewer')}
{shareRole === "editor"
? t("storageShare.roleEditor", "Editor")
: shareRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
)}
</Group>
</Group>
{status === 'loading' && (
{status === "loading" && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t('storageShare.loading', 'Loading share link...')}
{t("storageShare.loading", "Loading share link...")}
</Text>
</Group>
)}
{status === 'ready' && (
{status === "ready" && (
<>
<Text size="lg" fw={600}>
{title}
</Text>
<Text size="sm" c="dimmed">
{t('storageShare.ownerLabel', 'Owner')}: {ownerLabel}
{t("storageShare.ownerLabel", "Owner")}: {ownerLabel}
</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">
@@ -191,7 +186,7 @@ export default function ShareLinkPage() {
loading={isWorking}
disabled={!canOpen}
>
{t('storageShare.openInApp', 'Open in Stirling PDF')}
{t("storageShare.openInApp", "Open in Stirling PDF")}
</Button>
<Button
variant="light"
@@ -200,59 +195,51 @@ export default function ShareLinkPage() {
loading={isWorking}
disabled={!canDownload}
>
{t('storageShare.download', 'Download')}
{t("storageShare.download", "Download")}
</Button>
</Group>
{!canDownload && (
<Alert mt="md" color="yellow" title={t('storageShare.accessLimitedTitle', 'Limited access')}>
{shareRole === 'commenter'
<Alert mt="md" color="yellow" title={t("storageShare.accessLimitedTitle", "Limited access")}>
{shareRole === "commenter"
? t(
'storageShare.accessLimitedCommenter',
'Comment access is coming soon. Ask the owner for editor access if you need to download.'
"storageShare.accessLimitedCommenter",
"Comment access is coming soon. Ask the owner for editor access if you need to download.",
)
: t(
'storageShare.accessLimitedViewer',
'This link is view-only. Ask the owner for editor access if you need to download.'
"storageShare.accessLimitedViewer",
"This link is view-only. Ask the owner for editor access if you need to download.",
)}
</Alert>
)}
</>
)}
{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>
{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>
<Group mt="md">
<Button
leftSection={<LoginIcon style={{ fontSize: 18 }} />}
onClick={handleLogin}
>
{t('storageShare.goToLogin', 'Go to login')}
<Button leftSection={<LoginIcon style={{ fontSize: 18 }} />} onClick={handleLogin}>
{t("storageShare.goToLogin", "Go to login")}
</Button>
</Group>
</Alert>
)}
{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.'
)}
{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>
)}
{status === 'notfound' && (
<Alert color="red" title={t('storageShare.expiredTitle', 'Link expired')}>
{t('storageShare.expiredBody', 'This share link is invalid or has expired.')}
{status === "notfound" && (
<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.')}>
{t('storageShare.tryAgain', 'Please try again later.')}
{status === "error" && (
<Alert color="red" title={t("storageShare.loadFailed", "Unable to open shared file.")}>
{t("storageShare.tryAgain", "Please try again later.")}
</Alert>
)}
</Stack>
+33 -37
View File
@@ -1,19 +1,19 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import { useAuth } from '@app/auth/UseSession';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import '@app/routes/authShared/auth.css';
import { BASE_PATH } from '@app/constants/app';
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useAuth } from "@app/auth/UseSession";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import { BASE_PATH } from "@app/constants/app";
// Import signup components
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 { useAuthService } from '@app/routes/signup/AuthService';
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 { useAuthService } from "@app/routes/signup/AuthService";
export default function Signup() {
const navigate = useNavigate();
@@ -21,16 +21,16 @@ export default function Signup() {
const { session, loading } = useAuth();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
console.debug('[Signup] User already authenticated, redirecting to home');
navigate('/', { replace: true });
console.debug("[Signup] User already authenticated, redirecting to home");
navigate("/", { replace: true });
}
}, [session, loading, navigate]);
@@ -38,12 +38,12 @@ 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)'),
ogTitle: `${t('signup.title', 'Create an account')} - Stirling PDF`,
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
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)"),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
const { validateSignupForm } = useSignupFormValidation();
@@ -62,16 +62,16 @@ export default function Signup() {
setError(null);
setFieldErrors({});
const result = await signUp(email, password, '');
const result = await signUp(email, password, "");
if (result.user) {
// Show success message and redirect to login
setError(null);
setTimeout(() => navigate('/login'), 2000);
setTimeout(() => navigate("/login"), 2000);
}
} catch (err) {
console.error('[Signup] Unexpected error:', err);
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }));
console.error("[Signup] Unexpected error:", err);
setError(err instanceof Error ? err.message : t("signup.unexpectedError", { message: "Unknown error" }));
} finally {
setIsSigningUp(false);
}
@@ -79,7 +79,7 @@ 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,16 +98,12 @@ 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"
>
{t('login.logIn', 'Log In')}
<div style={{ textAlign: "center", margin: "0.5rem 0 0.25rem" }}>
<button type="button" onClick={() => navigate("/login")} className="auth-link-black">
{t("login.logIn", "Log In")}
</button>
</div>
</AuthLayout>

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