mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 04:08:22 +02:00
Restructure/frontend editor (#6404)
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,90 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route, useParams } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||
import ParticipantView from "@app/components/workflow/ParticipantView";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
import "@app/styles/index.css";
|
||||
import "@app/styles/auth-theme.css";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Participant signing page — token-gated, no login required
|
||||
function ParticipantViewPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
if (!token) return null;
|
||||
return <ParticipantView token={token} />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Participant signing — public, token-gated, no auth required */}
|
||||
<Route
|
||||
path="/workflow/sign/:token"
|
||||
element={
|
||||
<MobileScannerProviders>
|
||||
<ParticipantViewPage />
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
Stirling PDF User License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
License Scope & Usage Rights
|
||||
|
||||
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
|
||||
|
||||
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
|
||||
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
|
||||
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
|
||||
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
|
||||
covering the appropriate number of licensed users.
|
||||
|
||||
Trial and Minimal Use
|
||||
|
||||
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
|
||||
* Use is limited to the capabilities and restrictions defined by the Software itself;
|
||||
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
|
||||
|
||||
Continued use beyond this scope requires a valid Stirling PDF User License.
|
||||
|
||||
Modifications and Derivative Works
|
||||
|
||||
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
|
||||
|
||||
* May not be deployed in production environments without a valid User License;
|
||||
* May not be distributed or sublicensed;
|
||||
* Remain the intellectual property of Stirling PDF and/or its licensors;
|
||||
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
|
||||
|
||||
Prohibited Actions
|
||||
|
||||
Unless explicitly permitted by a paid license or separate agreement, you may not:
|
||||
|
||||
* Use the Software in production environments;
|
||||
* Copy, merge, distribute, sublicense, or sell the Software;
|
||||
* Remove or alter any licensing or copyright notices;
|
||||
* Circumvent access restrictions or licensing requirements.
|
||||
|
||||
Third-Party Components
|
||||
|
||||
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
|
||||
their original license terms as provided by their respective owners.
|
||||
|
||||
Disclaimer
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
||||
import type {
|
||||
Session,
|
||||
User,
|
||||
AuthError,
|
||||
AuthChangeEvent,
|
||||
} from "@app/auth/springAuthClient";
|
||||
|
||||
/**
|
||||
* Auth Context Type
|
||||
* Simplified version without SaaS-specific features (credits, subscriptions)
|
||||
*/
|
||||
interface AuthContextType {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth Provider Component
|
||||
*
|
||||
* Manages authentication state and provides it to the entire app.
|
||||
* Integrates with Spring Security + JWT backend.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
|
||||
// Debug: Track state transitions
|
||||
useEffect(() => {
|
||||
console.log("[Auth] State changed:", {
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
hasError: !!error,
|
||||
userId: session?.user?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}, [loading, session, error]);
|
||||
|
||||
/**
|
||||
* Refresh current session
|
||||
*/
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug("[Auth] refreshSession: start", {
|
||||
path: window.location.pathname,
|
||||
});
|
||||
console.debug("[Auth] Refreshing session...");
|
||||
|
||||
const { data, error } = await springAuth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Session refresh error:", error);
|
||||
setError(error);
|
||||
setSession(null);
|
||||
} else {
|
||||
console.debug("[Auth] Session refreshed successfully");
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during session refresh:", err);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign out user
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
console.debug("[Auth] Signing out...");
|
||||
|
||||
const { error } = await springAuth.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Sign out error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth] Signed out successfully");
|
||||
setSession(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during sign out:", err);
|
||||
setError(err as AuthError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize auth on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`);
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug(`[Auth:${mountId}] Initializing auth...`);
|
||||
console.debug(
|
||||
`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`,
|
||||
);
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.startsWith("/login")
|
||||
) {
|
||||
await clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
|
||||
// Skip config check entirely - let the app handle login state
|
||||
// The config will be fetched by useAppConfig when needed
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Initial session error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth] Initial session loaded:", {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
});
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Auth] Unexpected error during auth initialization:",
|
||||
err,
|
||||
);
|
||||
if (mounted) {
|
||||
setError(err as AuthError);
|
||||
}
|
||||
} finally {
|
||||
console.debug(
|
||||
`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`,
|
||||
);
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
||||
const handleJwtAvailable = () => {
|
||||
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
|
||||
console.log(`[Auth:${mountId}] 🔄 JWT available event received`);
|
||||
console.log(
|
||||
`[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`,
|
||||
);
|
||||
console.log(
|
||||
`[Auth:${mountId}] Setting loading=true to stabilize auth state`,
|
||||
);
|
||||
setLoading(true); // Prevent unstable renders during auth state transition
|
||||
setError(null);
|
||||
console.log(`[Auth:${mountId}] Refreshing session...`);
|
||||
void initializeAuth();
|
||||
};
|
||||
|
||||
window.addEventListener("jwt-available", handleJwtAvailable);
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = springAuth.onAuthStateChange(
|
||||
async (event: AuthChangeEvent, newSession: Session | null) => {
|
||||
if (!mounted) {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
|
||||
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
|
||||
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
|
||||
console.log(
|
||||
`[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`,
|
||||
);
|
||||
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
|
||||
|
||||
// Schedule state update
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
console.log(
|
||||
`[Auth:${mountId}] Applying session update (event: ${event})`,
|
||||
);
|
||||
setSession(newSession);
|
||||
setError(null);
|
||||
|
||||
// Handle specific events
|
||||
if (event === "SIGNED_OUT") {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ✓ User signed out, session cleared`,
|
||||
);
|
||||
} else if (event === "SIGNED_IN") {
|
||||
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
|
||||
} else if (event === "TOKEN_REFRESHED") {
|
||||
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
|
||||
} else if (event === "USER_UPDATED") {
|
||||
console.log(`[Auth:${mountId}] ✓ User updated`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
|
||||
mounted = false;
|
||||
window.removeEventListener("jwt-available", handleJwtAvailable);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth context
|
||||
* Must be used within AuthProvider
|
||||
*/
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug hook to expose auth state for debugging
|
||||
* Can be used in development to monitor auth state
|
||||
*/
|
||||
export function useAuthDebug() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[Auth Debug] Current auth state:", {
|
||||
hasSession: !!auth.session,
|
||||
hasUser: !!auth.user,
|
||||
loading: auth.loading,
|
||||
hasError: !!auth.error,
|
||||
userId: auth.user?.id,
|
||||
email: auth.user?.email,
|
||||
});
|
||||
}, [auth.session, auth.user, auth.loading, auth.error]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Helper utilities for clearing cached OAuth redirect/session state
|
||||
*/
|
||||
|
||||
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
|
||||
/**
|
||||
* Clear any persisted OAuth redirect path/cached state so the app
|
||||
* does not automatically resume a previous OAuth session after logout.
|
||||
*/
|
||||
export function resetOAuthState(): void {
|
||||
try {
|
||||
// Remove redirect cookie
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[OAuthStorage] Failed to clear redirect cookie", err);
|
||||
}
|
||||
|
||||
// Remove any related localStorage entries we might have used
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
|
||||
window.localStorage.removeItem("oauth_redirect_path");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[OAuthStorage] Failed to clear OAuth localStorage", err);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
resetOAuthState,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Known OAuth providers with dedicated UI support.
|
||||
* Custom providers are also supported - the backend determines availability.
|
||||
*/
|
||||
export const KNOWN_OAUTH_PROVIDERS = [
|
||||
"github",
|
||||
"google",
|
||||
"apple",
|
||||
"azure",
|
||||
"keycloak",
|
||||
"cloudron",
|
||||
"authentik",
|
||||
"oidc",
|
||||
] as const;
|
||||
|
||||
export type KnownOAuthProvider = (typeof KNOWN_OAUTH_PROVIDERS)[number];
|
||||
|
||||
/**
|
||||
* OAuth provider ID - can be any known provider or custom string.
|
||||
* The backend configuration determines which providers are available.
|
||||
*
|
||||
* @example 'github' | 'google' | 'mycompany' | 'authentik'
|
||||
*/
|
||||
export type OAuthProvider = KnownOAuthProvider | (string & {});
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import {
|
||||
consumePostLoginRedirectPath,
|
||||
isSafePostLoginRedirect,
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
setPostLoginRedirectPath,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
AxiosError,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock("@app/services/apiClient");
|
||||
vi.mock("@app/extensions/oauthNavigation", () => ({
|
||||
startOAuthNavigation: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
describe("SpringAuthClient", () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getSession", () => {
|
||||
it("should return null session when no JWT in localStorage", async () => {
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(result.data.session).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should validate JWT and return session when JWT exists", async () => {
|
||||
const mockToken = "mock-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "[email protected]",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { user: mockUser },
|
||||
} as unknown as AxiosResponse);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
|
||||
headers: { Authorization: `Bearer ${mockToken}` },
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
expect(result.data.session).toBeTruthy();
|
||||
expect(result.data.session?.user).toEqual(mockUser);
|
||||
expect(result.data.session?.access_token).toBe(mockToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear invalid JWT on 401 error", async () => {
|
||||
const mockToken = "invalid-jwt-token";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
const mockError = new AxiosError(
|
||||
"Unauthorized",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {} as InternalAxiosRequestConfig,
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 401 is handled gracefully, so error should be null
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear invalid JWT on 403 error", async () => {
|
||||
const mockToken = "forbidden-jwt-token";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
const mockError = new AxiosError(
|
||||
"Forbidden",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {} as InternalAxiosRequestConfig,
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 403 is handled gracefully, so error should be null
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInWithPassword", () => {
|
||||
it("should successfully sign in with email and password", async () => {
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
};
|
||||
|
||||
const mockToken = "new-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: credentials.email,
|
||||
username: credentials.email,
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as unknown as AxiosResponse);
|
||||
|
||||
// Spy on window.dispatchEvent
|
||||
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
|
||||
|
||||
const result = await springAuth.signInWithPassword(credentials);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/login",
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "jwt-available" }),
|
||||
);
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(result.session?.access_token).toBe(mockToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error on failed login", async () => {
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "wrongpassword",
|
||||
};
|
||||
|
||||
const errorMessage = "Invalid credentials";
|
||||
const mockError = Object.assign(new Error(errorMessage), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
data: { message: errorMessage },
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.signInWithPassword(credentials);
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error?.message).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUp", () => {
|
||||
it("should successfully register new user", async () => {
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "newpassword123",
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: "456",
|
||||
email: credentials.email,
|
||||
username: credentials.email,
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { user: mockUser },
|
||||
} as unknown as AxiosResponse);
|
||||
|
||||
const result = await springAuth.signUp(credentials);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/user/register",
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(result.session).toBeNull(); // No auto-login on signup
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error on failed registration", async () => {
|
||||
const credentials = {
|
||||
email: "[email protected]",
|
||||
password: "password123",
|
||||
};
|
||||
|
||||
const errorMessage = "User already exists";
|
||||
const mockError = Object.assign(new Error(errorMessage), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { message: errorMessage },
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.signUp(credentials);
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error?.message).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signOut", () => {
|
||||
it("should successfully sign out and clear JWT", async () => {
|
||||
const mockToken = "jwt-to-clear";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {},
|
||||
} as unknown as AxiosResponse);
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
"/api/v1/auth/logout",
|
||||
null,
|
||||
expect.objectContaining({ withCredentials: true }),
|
||||
);
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear JWT even if logout request fails", async () => {
|
||||
const mockToken = "jwt-to-clear";
|
||||
localStorage.setItem("stirling_jwt", mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
message: "Server error",
|
||||
});
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshSession", () => {
|
||||
it("should refresh JWT token successfully", async () => {
|
||||
const newToken = "refreshed-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "[email protected]",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: newToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as unknown as AxiosResponse);
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
|
||||
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
|
||||
expect(result.data.session?.access_token).toBe(newToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear JWT and return error on 401", async () => {
|
||||
localStorage.setItem("stirling_jwt", "expired-token");
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: "Token expired",
|
||||
});
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signInWithOAuth", () => {
|
||||
it("should redirect to OAuth provider", async () => {
|
||||
const mockAssign = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { assign: mockAssign },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await springAuth.signInWithOAuth({
|
||||
provider: "/oauth2/authorization/github",
|
||||
options: { redirectTo: "/auth/callback" },
|
||||
});
|
||||
|
||||
expect(startOAuthNavigation).toHaveBeenCalledWith(
|
||||
"/oauth2/authorization/github",
|
||||
);
|
||||
expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it("should skip redirect when handled by extension", async () => {
|
||||
const mockAssign = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { assign: mockAssign },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await springAuth.signInWithOAuth({
|
||||
provider: "/oauth2/authorization/github",
|
||||
options: { redirectTo: "/auth/callback" },
|
||||
});
|
||||
|
||||
expect(startOAuthNavigation).toHaveBeenCalledWith(
|
||||
"/oauth2/authorization/github",
|
||||
);
|
||||
expect(mockAssign).not.toHaveBeenCalled();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-login redirect path", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("isSafePostLoginRedirect", () => {
|
||||
it("accepts same-origin paths with a single leading slash", () => {
|
||||
expect(isSafePostLoginRedirect("/share/abc123")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/workbench")).toBe(true);
|
||||
expect(isSafePostLoginRedirect("/share/abc?x=1")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty, null, or non-string values", () => {
|
||||
expect(isSafePostLoginRedirect("")).toBe(false);
|
||||
expect(isSafePostLoginRedirect(null)).toBe(false);
|
||||
expect(isSafePostLoginRedirect(undefined)).toBe(false);
|
||||
expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => {
|
||||
expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("http://evil.example")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("https://evil.example/x")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects auth-plumbing paths to avoid login loops", () => {
|
||||
expect(isSafePostLoginRedirect("/login")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/login?foo=1")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
|
||||
expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPostLoginRedirectPath", () => {
|
||||
it("stores a safe path in sessionStorage", () => {
|
||||
setPostLoginRedirectPath("/share/abc123");
|
||||
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBe(
|
||||
"/share/abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears any existing entry when given an unsafe value", () => {
|
||||
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
|
||||
setPostLoginRedirectPath("//evil.example");
|
||||
expect(
|
||||
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clears any existing entry when given null", () => {
|
||||
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
|
||||
setPostLoginRedirectPath(null);
|
||||
expect(
|
||||
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumePostLoginRedirectPath", () => {
|
||||
it("returns the stored path and clears it (single-use)", () => {
|
||||
sessionStorage.setItem(
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
"/share/abc123",
|
||||
);
|
||||
expect(consumePostLoginRedirectPath()).toBe("/share/abc123");
|
||||
expect(
|
||||
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null (and still clears) when the stored value is unsafe", () => {
|
||||
sessionStorage.setItem(
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
"//evil.example",
|
||||
);
|
||||
expect(consumePostLoginRedirectPath()).toBeNull();
|
||||
expect(
|
||||
sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when nothing is stored", () => {
|
||||
expect(consumePostLoginRedirectPath()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,874 @@
|
||||
/**
|
||||
* Spring Auth Client
|
||||
*
|
||||
* This client integrates with the Spring Security + JWT backend.
|
||||
* - Uses localStorage for JWT storage (sent via Authorization header)
|
||||
* - JWT validation handled server-side
|
||||
* - No email confirmation flow (auto-confirmed on registration)
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { AxiosError } from "axios";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { type OAuthProvider } from "@app/auth/oauthTypes";
|
||||
import { resetOAuthState } from "@app/auth/oauthStorage";
|
||||
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
|
||||
import {
|
||||
getPlatformSessionUser,
|
||||
isDesktopSaaSAuthMode,
|
||||
refreshPlatformSession,
|
||||
savePlatformToken,
|
||||
} from "@app/extensions/platformSessionBridge";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
|
||||
function getHttpStatus(error: unknown): number | undefined {
|
||||
if (error instanceof AxiosError) {
|
||||
return error.response?.status;
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const response = (error as { response?: { status?: unknown } }).response;
|
||||
if (response && typeof response.status === "number") {
|
||||
return response.status;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to extract error message from axios error
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof AxiosError) {
|
||||
return (
|
||||
error.response?.data?.error ||
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
fallback
|
||||
);
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
|
||||
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
|
||||
|
||||
export const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
|
||||
|
||||
function normalizeRedirectPath(target?: string): string {
|
||||
if (!target || typeof target !== "string") {
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(target, window.location.origin);
|
||||
const path = parsed.pathname || "/";
|
||||
const query = parsed.search || "";
|
||||
return `${path}${query}`;
|
||||
} catch {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
}
|
||||
|
||||
function persistRedirectPath(path: string): void {
|
||||
try {
|
||||
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
} catch (_error) {
|
||||
// console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error);
|
||||
}
|
||||
}
|
||||
|
||||
// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
|
||||
// URLs to guard against open-redirect abuse if the stored value is tampered with.
|
||||
export function isSafePostLoginRedirect(path: unknown): path is string {
|
||||
if (typeof path !== "string" || path.length === 0) return false;
|
||||
if (!path.startsWith("/") || path.startsWith("//")) return false;
|
||||
if (path.startsWith("/\\")) return false;
|
||||
const lowered = path.toLowerCase();
|
||||
if (
|
||||
lowered.startsWith("/login") ||
|
||||
lowered.startsWith("/auth/") ||
|
||||
lowered.startsWith("/oauth2") ||
|
||||
lowered.startsWith("/saml2")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setPostLoginRedirectPath(
|
||||
path: string | null | undefined,
|
||||
): void {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isSafePostLoginRedirect(path)) {
|
||||
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
|
||||
}
|
||||
} catch (_error) {
|
||||
// sessionStorage unavailable (private mode) — fail open
|
||||
}
|
||||
}
|
||||
|
||||
export function consumePostLoginRedirectPath(): string | null {
|
||||
try {
|
||||
if (typeof window === "undefined") return null;
|
||||
const value = window.sessionStorage.getItem(
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
);
|
||||
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
|
||||
return isSafePostLoginRedirect(value) ? value : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
enabled?: boolean;
|
||||
is_anonymous?: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
authenticationType?: string;
|
||||
app_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
error: AuthError | null;
|
||||
}
|
||||
|
||||
export type AuthChangeEvent =
|
||||
| "SIGNED_IN"
|
||||
| "SIGNED_OUT"
|
||||
| "TOKEN_REFRESHED"
|
||||
| "USER_UPDATED";
|
||||
|
||||
type AuthChangeCallback = (
|
||||
event: AuthChangeEvent,
|
||||
session: Session | null,
|
||||
) => void;
|
||||
|
||||
class SpringAuthClient {
|
||||
private listeners: AuthChangeCallback[] = [];
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Adaptive intervals - calculated based on actual JWT token lifetime
|
||||
// Defaults for initial startup (will be recalculated on first token)
|
||||
private sessionCheckIntervalMs = 10000; // 10 seconds default
|
||||
private tokenRefreshThresholdMs = 30000; // 30 seconds default
|
||||
|
||||
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
|
||||
|
||||
constructor() {
|
||||
// Start periodic session validation
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal check interval and refresh threshold based on token lifetime.
|
||||
* - Check interval: token lifetime / 6 (check 6 times during token life)
|
||||
* - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
|
||||
* - Applies min/max bounds for sanity
|
||||
*/
|
||||
private calculateAdaptiveIntervals(token: string): void {
|
||||
try {
|
||||
const payload = this.decodeJwtPayload(token);
|
||||
if (!payload) {
|
||||
console.warn(
|
||||
"[SpringAuth] Cannot decode token for adaptive intervals, using defaults",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
|
||||
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
|
||||
|
||||
if (expSeconds <= 0 || iatSeconds <= 0) {
|
||||
console.warn(
|
||||
"[SpringAuth] Token missing exp/iat claims, using default intervals",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
|
||||
|
||||
// Check interval: check 6 times during token lifetime
|
||||
// Min: 5 seconds (for very short tokens)
|
||||
// Max: 60 seconds (don't check too infrequently)
|
||||
this.sessionCheckIntervalMs = Math.max(
|
||||
5000,
|
||||
Math.min(60000, tokenLifetimeMs / 6),
|
||||
);
|
||||
|
||||
// Refresh threshold: refresh when 25% of lifetime remaining
|
||||
// Min: 30 seconds (give buffer for refresh to complete)
|
||||
// Max: 5 minutes (don't wait too long for long-lived tokens)
|
||||
this.tokenRefreshThresholdMs = Math.max(
|
||||
30000,
|
||||
Math.min(300000, tokenLifetimeMs / 4),
|
||||
);
|
||||
|
||||
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
|
||||
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
|
||||
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + "s",
|
||||
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + "s",
|
||||
});
|
||||
|
||||
// Restart monitoring with new interval
|
||||
this.restartSessionMonitoring();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to calculate adaptive intervals:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
|
||||
|
||||
return JSON.parse(atob(base64));
|
||||
}
|
||||
|
||||
private getTokenExpiry(token: string): {
|
||||
expiresIn: number;
|
||||
expiresAt: number;
|
||||
} {
|
||||
try {
|
||||
const payload = this.decodeJwtPayload(token);
|
||||
if (!payload) {
|
||||
throw new Error("Token payload missing");
|
||||
}
|
||||
|
||||
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
|
||||
const expiresAt =
|
||||
expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
|
||||
const expiresIn = Math.max(
|
||||
0,
|
||||
Math.floor((expiresAt - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
return { expiresIn, expiresAt };
|
||||
} catch {
|
||||
// Fallback for non-JWT or malformed tokens.
|
||||
const expiresAt = Date.now() + 3600 * 1000;
|
||||
return { expiresIn: 3600, expiresAt };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get CSRF token from cookie
|
||||
*/
|
||||
private getCsrfToken(): string | null {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split("=");
|
||||
if (name === "XSRF-TOKEN") {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session
|
||||
* JWT is stored in localStorage and sent via Authorization header
|
||||
*/
|
||||
async getSession(): Promise<{
|
||||
data: { session: Session | null };
|
||||
error: AuthError | null;
|
||||
}> {
|
||||
try {
|
||||
// Get JWT from localStorage
|
||||
let token = localStorage.getItem("stirling_jwt");
|
||||
|
||||
if (!token) {
|
||||
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
let tokenExpiry = this.getTokenExpiry(token);
|
||||
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const refreshedToken = localStorage.getItem("stirling_jwt");
|
||||
if (!refreshedToken) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
token = refreshedToken;
|
||||
tokenExpiry = this.getTokenExpiry(token);
|
||||
}
|
||||
|
||||
if (tokenExpiry.expiresIn <= 0) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const platformUser = await getPlatformSessionUser();
|
||||
|
||||
const session: Session = {
|
||||
user: {
|
||||
id:
|
||||
platformUser?.email ||
|
||||
platformUser?.username ||
|
||||
"desktop-saas-user",
|
||||
email: platformUser?.email || "",
|
||||
username: platformUser?.username || platformUser?.email || "User",
|
||||
role: "USER",
|
||||
},
|
||||
access_token: token,
|
||||
expires_in: tokenExpiry.expiresIn,
|
||||
expires_at: tokenExpiry.expiresAt,
|
||||
};
|
||||
|
||||
return { data: { session }, error: null };
|
||||
}
|
||||
|
||||
// Verify with backend
|
||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||
const response = await apiClient.get("/api/v1/auth/me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
// Session bootstrap should not trigger global 401 refresh/redirect loops.
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
|
||||
// console.debug('[SpringAuth] /me response status:', response.status);
|
||||
const data = response.data;
|
||||
// console.debug('[SpringAuth] /me response data:', data);
|
||||
|
||||
// Create session object
|
||||
const tokenExpiry = this.getTokenExpiry(token);
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: tokenExpiry.expiresIn,
|
||||
expires_at: tokenExpiry.expiresAt,
|
||||
};
|
||||
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] getSession error:", error);
|
||||
|
||||
// If 401/403, token is invalid - try explicit refresh
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
// A 401 during startup can be a race with a concurrent refresh. Try one
|
||||
// explicit refresh before treating the session as invalid.
|
||||
const refreshResult = await this.refreshSession();
|
||||
if (!refreshResult.error && refreshResult.data.session) {
|
||||
return refreshResult;
|
||||
}
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
console.debug("[SpringAuth] getSession: Not authenticated");
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
// Don't clear token for other errors (e.g., backend not ready, network issues)
|
||||
// The token is still valid, just can't verify it right now
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: getErrorMessage(error, "Unknown error") },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signInWithPassword(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
"/api/v1/auth/login",
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
mfaCode: credentials.mfaCode,
|
||||
},
|
||||
{
|
||||
withCredentials: true, // Include cookies for CSRF
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
const token = data.session.access_token;
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||
await savePlatformToken(token);
|
||||
|
||||
// Calculate adaptive monitoring intervals based on token lifetime
|
||||
this.calculateAdaptiveIntervals(token);
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: data.session.expires_in,
|
||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners("SIGNED_IN", session);
|
||||
|
||||
return { user: data.user, session, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] signInWithPassword error:", error);
|
||||
if (error instanceof AxiosError) {
|
||||
const errorCode = error.response?.data?.error as string | undefined;
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"Login failed";
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
status: error.response?.status,
|
||||
code: errorCode,
|
||||
mfaRequired: errorCode === "mfa_required",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: getErrorMessage(error, "Login failed") },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign up new user
|
||||
*/
|
||||
async signUp(credentials: {
|
||||
email: string;
|
||||
password: string;
|
||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
"/api/v1/user/register",
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
{
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
|
||||
// Note: Spring backend auto-confirms users (no email verification)
|
||||
// Return user but no session (user needs to login)
|
||||
return { user: data.user, session: null, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] signUp error:", error);
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: getErrorMessage(error, "Registration failed") },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.)
|
||||
* This redirects to the Spring OAuth2/SAML2 authorization endpoint
|
||||
*
|
||||
* @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
|
||||
* The backend provides the complete path including the auth type and provider ID
|
||||
*/
|
||||
async signInWithOAuth(params: {
|
||||
provider: OAuthProvider;
|
||||
options?: { redirectTo?: string; queryParams?: Record<string, string> };
|
||||
}): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
|
||||
persistRedirectPath(redirectPath);
|
||||
|
||||
// Use the full path provided by the backend
|
||||
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
|
||||
const redirectUrl = params.provider;
|
||||
const handled = await startOAuthNavigation(redirectUrl);
|
||||
if (handled) {
|
||||
return { error: null };
|
||||
}
|
||||
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
|
||||
// Use window.location.assign for full page navigation
|
||||
window.location.assign(redirectUrl);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
error instanceof Error ? error.message : "SSO redirect failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out user (invalidate session)
|
||||
*/
|
||||
async signOut(): Promise<{ error: AuthError | null }> {
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem(
|
||||
"stirling_sso_auto_login_logged_out",
|
||||
"1",
|
||||
);
|
||||
}
|
||||
const response = await apiClient.post("/api/v1/auth/logout", null, {
|
||||
headers: {
|
||||
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
// console.debug('[SpringAuth] signOut: Success');
|
||||
}
|
||||
|
||||
// Clean up local storage
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
try {
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
|
||||
.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
// Clear any cached OAuth redirect/session state
|
||||
resetOAuthState();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to clear Supabase/local auth tokens",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear cookies that might hold refresh/session tokens
|
||||
try {
|
||||
document.cookie.split(";").forEach((cookie) => {
|
||||
const eqPos = cookie.indexOf("=");
|
||||
const name =
|
||||
eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[SpringAuth] Failed to clear cookies on sign out", err);
|
||||
}
|
||||
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to run platform auth cleanup",
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners("SIGNED_OUT", null);
|
||||
|
||||
return { error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] signOut error:", error);
|
||||
// Still remove token even if backend call fails
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to run platform auth cleanup after error",
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
return {
|
||||
error: { message: getErrorMessage(error, "Logout failed") },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh JWT token
|
||||
*/
|
||||
async refreshSession(): Promise<{
|
||||
data: { session: Session | null };
|
||||
error: AuthError | null;
|
||||
}> {
|
||||
try {
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: "Token refresh failed - please log in again" },
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await this.getSession();
|
||||
if (error || !data.session) {
|
||||
return {
|
||||
data: { session: null },
|
||||
error: error || {
|
||||
message: "Token refresh failed - please log in again",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate adaptive intervals for desktop SaaS mode
|
||||
const token = localStorage.getItem("stirling_jwt");
|
||||
if (token) {
|
||||
this.calculateAdaptiveIntervals(token);
|
||||
}
|
||||
|
||||
this.notifyListeners("TOKEN_REFRESHED", data.session);
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/api/v1/auth/refresh", null, {
|
||||
headers: {
|
||||
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
||||
},
|
||||
withCredentials: true,
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const token = data.session.access_token;
|
||||
|
||||
// Update local storage with new token
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
|
||||
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||
await savePlatformToken(token);
|
||||
|
||||
// Calculate adaptive monitoring intervals based on token lifetime
|
||||
this.calculateAdaptiveIntervals(token);
|
||||
|
||||
const session: Session = {
|
||||
user: data.user,
|
||||
access_token: token,
|
||||
expires_in: data.session.expires_in,
|
||||
expires_at: Date.now() + data.session.expires_in * 1000,
|
||||
};
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners("TOKEN_REFRESHED", session);
|
||||
|
||||
console.debug("[SpringAuth] Token refreshed successfully");
|
||||
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error("[SpringAuth] refreshSession error:", error);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
|
||||
// Handle different error statuses
|
||||
const status = getHttpStatus(error);
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: "Token refresh failed - please log in again" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: { session: null },
|
||||
error: { message: getErrorMessage(error, "Token refresh failed") },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to auth state changes
|
||||
*/
|
||||
onAuthStateChange(callback: AuthChangeCallback): {
|
||||
data: { subscription: { unsubscribe: () => void } };
|
||||
} {
|
||||
this.listeners.push(callback);
|
||||
|
||||
return {
|
||||
data: {
|
||||
subscription: {
|
||||
unsubscribe: () => {
|
||||
this.listeners = this.listeners.filter((cb) => cb !== callback);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
|
||||
// Use setTimeout to avoid calling callbacks synchronously
|
||||
setTimeout(() => {
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(event, session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[SpringAuth] Error in auth state change listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private startSessionMonitoring() {
|
||||
// Periodically check session validity
|
||||
// Interval is adaptive based on token lifetime (calculated when token is received)
|
||||
this.sessionCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// Try to get current session
|
||||
const { data } = await this.getSession();
|
||||
|
||||
// If we have a session, proactively refresh if needed
|
||||
if (data.session) {
|
||||
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
||||
|
||||
// Refresh if token expires soon (threshold is adaptive)
|
||||
if (
|
||||
timeUntilExpiry > 0 &&
|
||||
timeUntilExpiry < this.tokenRefreshThresholdMs
|
||||
) {
|
||||
console.log(
|
||||
"[SpringAuth] 🔄 Proactively refreshing token (expires in " +
|
||||
Math.floor(timeUntilExpiry / 1000) +
|
||||
"s)",
|
||||
);
|
||||
await this.refreshSession();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SpringAuth] Session monitoring error:", error);
|
||||
}
|
||||
}, this.sessionCheckIntervalMs);
|
||||
}
|
||||
|
||||
private restartSessionMonitoring() {
|
||||
// Stop existing interval
|
||||
if (this.sessionCheckInterval) {
|
||||
clearInterval(this.sessionCheckInterval);
|
||||
this.sessionCheckInterval = null;
|
||||
}
|
||||
// Start with new interval
|
||||
this.startSessionMonitoring();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
if (this.sessionCheckInterval) {
|
||||
clearInterval(this.sessionCheckInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const springAuth = new SpringAuthClient();
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
export const getCurrentUser = async () => {
|
||||
const { data } = await springAuth.getSession();
|
||||
return data.session?.user || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user is anonymous
|
||||
*/
|
||||
export const isUserAnonymous = (user: User | null) => {
|
||||
return user?.is_anonymous === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an anonymous user object for use when login is disabled
|
||||
* This provides a consistent User interface throughout the app
|
||||
*/
|
||||
export const createAnonymousUser = (): User => {
|
||||
return {
|
||||
id: "anonymous",
|
||||
email: "anonymous@local",
|
||||
username: "Anonymous User",
|
||||
role: "USER",
|
||||
enabled: true,
|
||||
is_anonymous: true,
|
||||
app_metadata: {
|
||||
provider: "anonymous",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an anonymous session for use when login is disabled
|
||||
*/
|
||||
export const createAnonymousSession = (): Session => {
|
||||
return {
|
||||
user: createAnonymousUser(),
|
||||
access_token: "",
|
||||
expires_in: Number.MAX_SAFE_INTEGER,
|
||||
expires_at: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
};
|
||||
|
||||
// Export auth client as default for convenience
|
||||
export default springAuth;
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
AppProviders as CoreAppProviders,
|
||||
AppProvidersProps,
|
||||
} from "@core/components/AppProviders";
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
|
||||
export function AppProviders({
|
||||
children,
|
||||
appConfigRetryOptions,
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
return (
|
||||
<CoreAppProviders
|
||||
appConfigRetryOptions={appConfigRetryOptions}
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<AuthProvider>
|
||||
<LicenseProvider>
|
||||
<UpdateSeatsProvider>
|
||||
<ServerExperienceProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBannerInitializer />
|
||||
{children}
|
||||
</CheckoutProvider>
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
</LicenseProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
CloseButton,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
ChangeUserPasswordRequest,
|
||||
User,
|
||||
userManagementService,
|
||||
} from "@app/services/userManagementService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface ChangeUserPasswordModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
user: User | null;
|
||||
onSuccess: () => void;
|
||||
mailEnabled: boolean;
|
||||
}
|
||||
|
||||
function generateSecurePassword() {
|
||||
const charset =
|
||||
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789@$!%*?&";
|
||||
const length = 14;
|
||||
let password = "";
|
||||
const charsetLength = charset.length;
|
||||
const uint8Array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(uint8Array);
|
||||
// To avoid modulo bias, discard values >= 256 - (256 % charsetLength)
|
||||
for (let i = 0; password.length < length; ) {
|
||||
const randomByte = uint8Array[i];
|
||||
i++;
|
||||
if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) {
|
||||
// Discard and generate a new random value
|
||||
if (i >= uint8Array.length) {
|
||||
// Exhausted the array, fill a new one
|
||||
window.crypto.getRandomValues(uint8Array);
|
||||
i = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const randomIndex = randomByte % charsetLength;
|
||||
password += charset[randomIndex];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
export default function ChangeUserPasswordModal({
|
||||
opened,
|
||||
onClose,
|
||||
user,
|
||||
onSuccess,
|
||||
mailEnabled,
|
||||
}: ChangeUserPasswordModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const disabled = !user;
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const generated = generateSecurePassword();
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
newPassword: generated,
|
||||
confirmPassword: generated,
|
||||
generateRandom: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!form.newPassword) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(form.newPassword);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.changePassword.copiedToClipboard",
|
||||
"Password copied to clipboard",
|
||||
),
|
||||
});
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.changePassword.copyFailed",
|
||||
"Failed to copy password",
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setForm({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (processing) return;
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (!form.generateRandom && !form.newPassword.trim()) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.changePassword.passwordRequired",
|
||||
"Please enter a new password",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.generateRandom && form.newPassword !== form.confirmPassword) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.changePassword.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ChangeUserPasswordRequest = {
|
||||
username: user.username,
|
||||
newPassword: form.newPassword, // Always send the password (frontend generates it when generateRandom is true)
|
||||
generateRandom: false, // Not needed since we're generating on frontend
|
||||
sendEmail: form.sendEmail,
|
||||
includePassword: form.includePassword,
|
||||
forcePasswordChange: form.forcePasswordChange,
|
||||
};
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.changeUserPassword(payload);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.changePassword.success",
|
||||
"Password updated successfully",
|
||||
),
|
||||
});
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t(
|
||||
"workspace.people.changePassword.error",
|
||||
"Failed to update password",
|
||||
);
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setForm({
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
generateRandom: false,
|
||||
sendEmail: false,
|
||||
includePassword: false,
|
||||
forcePasswordChange: false,
|
||||
});
|
||||
}
|
||||
}, [opened, user?.username]);
|
||||
|
||||
// Check if username is a valid email format
|
||||
const isValidEmail = (email: string | undefined) => {
|
||||
if (!email) return false;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const canEmail = mailEnabled && isValidEmail(user?.username);
|
||||
const passwordPreview = useMemo(
|
||||
() => (form.newPassword && form.generateRandom ? form.newPassword : ""),
|
||||
[form.generateRandom, form.newPassword],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
disabled={processing}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="lock"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.people.changePassword.title", "Change password")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t(
|
||||
"workspace.people.changePassword.subtitle",
|
||||
"Update the password for",
|
||||
)}{" "}
|
||||
<strong>{user?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.newPassword",
|
||||
"New password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.placeholder",
|
||||
"Enter a new password",
|
||||
)}
|
||||
value={form.newPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
newPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"workspace.people.changePassword.confirmPassword",
|
||||
"Confirm password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.changePassword.confirmPlaceholder",
|
||||
"Re-enter the new password",
|
||||
)}
|
||||
value={form.confirmPassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
confirmPassword: event.currentTarget.value,
|
||||
generateRandom: false,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled || form.generateRandom}
|
||||
error={
|
||||
!form.generateRandom &&
|
||||
form.confirmPassword &&
|
||||
form.newPassword !== form.confirmPassword
|
||||
? t(
|
||||
"workspace.people.changePassword.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.generateRandom",
|
||||
"Generate secure password",
|
||||
)}
|
||||
checked={form.generateRandom}
|
||||
disabled={processing || disabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
setForm((prev) => ({ ...prev, generateRandom: checked }));
|
||||
if (event.currentTarget.checked) {
|
||||
handleGeneratePassword();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{passwordPreview && (
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"workspace.people.changePassword.generatedPreview",
|
||||
"Generated password:",
|
||||
)}{" "}
|
||||
<strong>{passwordPreview}</strong>
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"workspace.people.changePassword.copyTooltip",
|
||||
"Copy to clipboard",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={processing}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="content-copy"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.sendEmail",
|
||||
"Email the user about this change",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, sendEmail: event.currentTarget.checked })
|
||||
}
|
||||
disabled={!canEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.includePassword",
|
||||
"Include the new password in the email",
|
||||
)}
|
||||
checked={canEmail && form.sendEmail && form.includePassword}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
includePassword: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!canEmail || !form.sendEmail || processing}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.changePassword.forcePasswordChange",
|
||||
"Force user to change password on next login",
|
||||
)}
|
||||
checked={form.forcePasswordChange}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
forcePasswordChange: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={processing || disabled}
|
||||
/>
|
||||
{!canEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{mailEnabled
|
||||
? t(
|
||||
"workspace.people.changePassword.emailUnavailable",
|
||||
"This user's email is not a valid email address. Notifications are disabled.",
|
||||
)
|
||||
: t(
|
||||
"workspace.people.changePassword.smtpDisabled",
|
||||
"Email notifications require SMTP to be enabled in settings.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{canEmail && !form.includePassword && form.sendEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"workspace.people.changePassword.notifyOnly",
|
||||
"An email will be sent without the password, letting the user know an admin changed it.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
disabled={disabled}
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.people.changePassword.submit", "Update password")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "@app/components/shared/dividerWithText/DividerWithText.css";
|
||||
|
||||
interface TextDividerProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
variant?: "default" | "subcategory";
|
||||
respondsToDarkMode?: boolean;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export default function DividerWithText({
|
||||
text,
|
||||
className = "",
|
||||
style,
|
||||
variant = "default",
|
||||
respondsToDarkMode = true,
|
||||
opacity,
|
||||
}: TextDividerProps) {
|
||||
const variantClass = variant === "subcategory" ? "subcategory" : "";
|
||||
const themeClass = respondsToDarkMode ? "" : "force-light";
|
||||
const styleWithOpacity =
|
||||
opacity !== undefined
|
||||
? { ...(style || {}), ["--text-divider-opacity" as string]: opacity }
|
||||
: style;
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
<div
|
||||
className={`text-divider ${variantClass} ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
>
|
||||
<div className="text-divider__rule" />
|
||||
<span className="text-divider__label">{text}</span>
|
||||
<div className="text-divider__rule" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-px my-2.5 ${themeClass} ${className}`}
|
||||
style={styleWithOpacity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
TextInput,
|
||||
Select,
|
||||
Paper,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
CloseButton,
|
||||
Box,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { userManagementService } from "@app/services/userManagementService";
|
||||
import { teamService, Team } from "@app/services/teamService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface InviteMembersModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function InviteMembersModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: InviteMembersModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [inviteMode, setInviteMode] = useState<"email" | "direct" | "link">(
|
||||
"direct",
|
||||
);
|
||||
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const actionTakenRef = useRef(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
grandfatheredUserCount: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
totalUsers: number;
|
||||
} | null>(null);
|
||||
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
|
||||
|
||||
// Form state for direct invite
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined as number | undefined,
|
||||
authType: "WEB" as "WEB" | "OAUTH2" | "SAML2",
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
|
||||
// Form state for email invite
|
||||
const [emailInviteForm, setEmailInviteForm] = useState({
|
||||
emails: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// Form state for invite link
|
||||
const [inviteLinkForm, setInviteLinkForm] = useState({
|
||||
email: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined as number | undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
|
||||
// Fetch teams and license info
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [adminData, teamsData] = await Promise.all([
|
||||
userManagementService.getUsers(),
|
||||
teamService.getTeams(),
|
||||
]);
|
||||
|
||||
setTeams(teamsData);
|
||||
|
||||
setLicenseInfo({
|
||||
maxAllowedUsers: adminData.maxAllowedUsers,
|
||||
availableSlots: adminData.availableSlots,
|
||||
grandfatheredUserCount: adminData.grandfatheredUserCount,
|
||||
licenseMaxUsers: adminData.licenseMaxUsers,
|
||||
premiumEnabled: adminData.premiumEnabled,
|
||||
totalUsers: adminData.totalUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch data:", error);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "ROLE_USER",
|
||||
label: t("workspace.people.roleDescriptions.user", "User"),
|
||||
},
|
||||
{
|
||||
value: "ROLE_ADMIN",
|
||||
label: t("workspace.people.roleDescriptions.admin", "Admin"),
|
||||
},
|
||||
];
|
||||
|
||||
const teamOptions = teams.map((team) => ({
|
||||
value: team.id.toString(),
|
||||
label: team.name,
|
||||
}));
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!inviteForm.username) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.people.addMember.usernameRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Password is only required for WEB auth type
|
||||
if (inviteForm.authType === "WEB" && !inviteForm.password) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.addMember.passwordRequired",
|
||||
"Password is required",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.createUser({
|
||||
username: inviteForm.username,
|
||||
password: inviteForm.password,
|
||||
role: inviteForm.role,
|
||||
teamId: inviteForm.teamId,
|
||||
authType: inviteForm.authType,
|
||||
forceChange: inviteForm.forceChange,
|
||||
forceMFA: inviteForm.forceMFA,
|
||||
});
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.people.addMember.success"),
|
||||
});
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
// Reset form
|
||||
setInviteForm({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
authType: "WEB",
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to invite user:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.people.addMember.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailInvite = async () => {
|
||||
if (!emailInviteForm.emails.trim()) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.emailInvite.emailsRequired",
|
||||
"Email addresses are required",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
const response = await userManagementService.inviteUsers({
|
||||
emails: emailInviteForm.emails, // comma-separated string as required by API
|
||||
role: emailInviteForm.role,
|
||||
teamId: emailInviteForm.teamId,
|
||||
});
|
||||
|
||||
if (response.successCount > 0) {
|
||||
// Show success message
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.people.emailInvite.success", {
|
||||
count: response.successCount,
|
||||
defaultValue: `Successfully invited ${response.successCount} user(s)`,
|
||||
}),
|
||||
});
|
||||
|
||||
// Show warning if there were partial failures
|
||||
if (response.failureCount > 0 && response.errors) {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t(
|
||||
"workspace.people.emailInvite.partialFailure",
|
||||
"Some invites failed",
|
||||
),
|
||||
body: response.errors,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
setEmailInviteForm({
|
||||
emails: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.people.emailInvite.allFailed",
|
||||
"Failed to invite users",
|
||||
),
|
||||
body: response.errors || response.error,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to invite users:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.people.emailInvite.error", "Failed to send invites");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateInviteLink = async () => {
|
||||
try {
|
||||
setProcessing(true);
|
||||
const response = await userManagementService.generateInviteLink({
|
||||
email: inviteLinkForm.email || undefined,
|
||||
role: inviteLinkForm.role,
|
||||
teamId: inviteLinkForm.teamId,
|
||||
expiryHours: inviteLinkForm.expiryHours,
|
||||
sendEmail: inviteLinkForm.sendEmail,
|
||||
frontendBaseUrl: config?.frontendUrl || window.location.origin,
|
||||
});
|
||||
actionTakenRef.current = true;
|
||||
setGeneratedInviteLink(response.inviteUrl);
|
||||
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.inviteLink.emailSent",
|
||||
"Invite link generated and sent via email",
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to generate invite link:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t(
|
||||
"workspace.people.inviteLink.error",
|
||||
"Failed to generate invite link",
|
||||
);
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (actionTakenRef.current) {
|
||||
onSuccess?.();
|
||||
actionTakenRef.current = false;
|
||||
}
|
||||
setGeneratedInviteLink(null);
|
||||
setInviteMode("direct");
|
||||
setInviteForm({
|
||||
username: "",
|
||||
password: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
authType: "WEB",
|
||||
forceChange: false,
|
||||
forceMFA: false,
|
||||
});
|
||||
setEmailInviteForm({
|
||||
emails: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
});
|
||||
setInviteLinkForm({
|
||||
email: "",
|
||||
role: "ROLE_USER",
|
||||
teamId: undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleGoToPlan = () => {
|
||||
handleClose();
|
||||
navigate("/settings/adminPlan");
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (inviteMode === "email") {
|
||||
handleEmailInvite();
|
||||
} else if (inviteMode === "link") {
|
||||
handleGenerateInviteLink();
|
||||
} else {
|
||||
handleInviteUser();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Box pos="relative">
|
||||
<CloseButton
|
||||
onClick={handleClose}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="person-add"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.people.inviteMembers.label", "Invite Members")}
|
||||
</Text>
|
||||
{inviteMode === "email" && (
|
||||
<Text size="sm" c="dimmed" ta="center" px="md">
|
||||
{t(
|
||||
"workspace.people.inviteMembers.subtitle",
|
||||
"Type or paste in emails below, separated by commas. Your workspace will be billed by members.",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* License Warning/Info */}
|
||||
{licenseInfo && (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
bg={
|
||||
licenseInfo.availableSlots === 0
|
||||
? "var(--mantine-color-red-light)"
|
||||
: "var(--mantine-color-blue-light)"
|
||||
}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<LocalIcon
|
||||
icon={licenseInfo.availableSlots > 0 ? "info" : "warning"}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{licenseInfo.availableSlots > 0
|
||||
? t("workspace.people.license.slotsAvailable", {
|
||||
count: licenseInfo.availableSlots,
|
||||
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`,
|
||||
})
|
||||
: t(
|
||||
"workspace.people.license.noSlotsAvailable",
|
||||
"No user slots available",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Button size="xs" variant="light" onClick={handleGoToPlan}>
|
||||
{t("workspace.people.actions.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("workspace.people.license.currentUsage", {
|
||||
current: licenseInfo.totalUsers,
|
||||
max: licenseInfo.maxAllowedUsers,
|
||||
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<Tooltip
|
||||
label={t(
|
||||
"workspace.people.inviteMode.emailDisabled",
|
||||
"Email invites require SMTP configuration and mail.enableInvites=true in settings",
|
||||
)}
|
||||
disabled={!!config?.enableEmailInvites}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
|
||||
>
|
||||
<div>
|
||||
<SegmentedControl
|
||||
value={inviteMode}
|
||||
onChange={(value) => {
|
||||
setInviteMode(value as "email" | "direct" | "link");
|
||||
setGeneratedInviteLink(null);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
label: t(
|
||||
"workspace.people.inviteMode.username",
|
||||
"Username",
|
||||
),
|
||||
value: "direct",
|
||||
},
|
||||
{
|
||||
label: t("workspace.people.inviteMode.link", "Link"),
|
||||
value: "link",
|
||||
},
|
||||
{
|
||||
label: t("workspace.people.inviteMode.email", "Email"),
|
||||
value: "email",
|
||||
disabled: !config?.enableEmailInvites,
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Link Mode */}
|
||||
{inviteMode === "link" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t(
|
||||
"workspace.people.inviteLink.email",
|
||||
"Email (optional)",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.inviteLink.emailPlaceholder",
|
||||
"[email protected]",
|
||||
)}
|
||||
value={inviteLinkForm.email}
|
||||
onChange={(e) =>
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
email: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
description={t(
|
||||
"workspace.people.inviteLink.emailDescription",
|
||||
"If provided, the link will be tied to this email address",
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
label={t("workspace.people.addMember.role")}
|
||||
data={roleOptions}
|
||||
value={inviteLinkForm.role}
|
||||
onChange={(value) =>
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
role: value || "ROLE_USER",
|
||||
})
|
||||
}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("workspace.people.addMember.team")}
|
||||
placeholder={t("workspace.people.addMember.teamPlaceholder")}
|
||||
data={teamOptions}
|
||||
value={inviteLinkForm.teamId?.toString()}
|
||||
onChange={(value) =>
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
teamId: value ? parseInt(value) : undefined,
|
||||
})
|
||||
}
|
||||
clearable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label={t(
|
||||
"workspace.people.inviteLink.expiryHours",
|
||||
"Link expires in (hours)",
|
||||
)}
|
||||
type="number"
|
||||
value={inviteLinkForm.expiryHours}
|
||||
onChange={(e) =>
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
expiryHours: parseInt(e.currentTarget.value) || 72,
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={720}
|
||||
/>
|
||||
{inviteLinkForm.email && (
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.inviteLink.sendEmail",
|
||||
"Send invite link via email",
|
||||
)}
|
||||
description={t(
|
||||
"workspace.people.inviteLink.sendEmailDescription",
|
||||
"Also send the link to the provided email address",
|
||||
)}
|
||||
checked={inviteLinkForm.sendEmail}
|
||||
onChange={(e) =>
|
||||
setInviteLinkForm({
|
||||
...inviteLinkForm,
|
||||
sendEmail: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display generated link */}
|
||||
{generatedInviteLink && (
|
||||
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"workspace.people.inviteLink.generated",
|
||||
"Invite Link Generated",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
value={generatedInviteLink}
|
||||
readOnly
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
generatedInviteLink,
|
||||
);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.inviteLink.copied",
|
||||
"Link copied to clipboard!",
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// Fallback for browsers without clipboard API
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = generatedInviteLink;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.inviteLink.copied",
|
||||
"Link copied to clipboard!",
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="content-copy"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email Mode */}
|
||||
{inviteMode === "email" && config?.enableEmailInvites && (
|
||||
<>
|
||||
<Textarea
|
||||
label={t(
|
||||
"workspace.people.emailInvite.emails",
|
||||
"Email Addresses",
|
||||
)}
|
||||
placeholder={t(
|
||||
"workspace.people.emailInvite.emailsPlaceholder",
|
||||
"[email protected], [email protected]",
|
||||
)}
|
||||
value={emailInviteForm.emails}
|
||||
onChange={(e) =>
|
||||
setEmailInviteForm({
|
||||
...emailInviteForm,
|
||||
emails: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
minRows={3}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("workspace.people.addMember.role")}
|
||||
data={roleOptions}
|
||||
value={emailInviteForm.role}
|
||||
onChange={(value) =>
|
||||
setEmailInviteForm({
|
||||
...emailInviteForm,
|
||||
role: value || "ROLE_USER",
|
||||
})
|
||||
}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("workspace.people.addMember.team")}
|
||||
placeholder={t("workspace.people.addMember.teamPlaceholder")}
|
||||
data={teamOptions}
|
||||
value={emailInviteForm.teamId?.toString()}
|
||||
onChange={(value) =>
|
||||
setEmailInviteForm({
|
||||
...emailInviteForm,
|
||||
teamId: value ? parseInt(value) : undefined,
|
||||
})
|
||||
}
|
||||
clearable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Direct/Username Mode */}
|
||||
{inviteMode === "direct" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("workspace.people.addMember.username")}
|
||||
placeholder={t(
|
||||
"workspace.people.addMember.usernamePlaceholder",
|
||||
)}
|
||||
value={inviteForm.username}
|
||||
onChange={(e) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
username: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Auth Type Selector - only show if SSO is enabled */}
|
||||
{(config?.enableOAuth || config?.enableSaml) && (
|
||||
<Select
|
||||
label={t(
|
||||
"workspace.people.addMember.authType",
|
||||
"Authentication Type",
|
||||
)}
|
||||
data={[
|
||||
{
|
||||
value: "WEB",
|
||||
label: t(
|
||||
"workspace.people.authType.password",
|
||||
"Password",
|
||||
),
|
||||
},
|
||||
...(config?.enableOAuth
|
||||
? [
|
||||
{
|
||||
value: "OAUTH2",
|
||||
label: t(
|
||||
"workspace.people.authType.oauth",
|
||||
"OAuth2",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(config?.enableSaml
|
||||
? [
|
||||
{
|
||||
value: "SAML2",
|
||||
label: t("workspace.people.authType.saml", "SAML2"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
value={inviteForm.authType}
|
||||
onChange={(value) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
authType: (value as "WEB" | "OAUTH2" | "SAML2") || "WEB",
|
||||
})
|
||||
}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
description={
|
||||
inviteForm.authType !== "WEB"
|
||||
? t(
|
||||
"workspace.people.authType.ssoDescription",
|
||||
"User will authenticate via SSO provider",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password field - only required for WEB auth type */}
|
||||
{inviteForm.authType === "WEB" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("workspace.people.addMember.password")}
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"workspace.people.addMember.passwordPlaceholder",
|
||||
)}
|
||||
value={inviteForm.password}
|
||||
onChange={(e) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
password: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.addMember.forcePasswordChange",
|
||||
"Force password change on first login",
|
||||
)}
|
||||
checked={inviteForm.forceChange}
|
||||
onChange={(e) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
forceChange: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"workspace.people.addMember.forceMFA",
|
||||
"Force MFA setup on next login",
|
||||
)}
|
||||
checked={inviteForm.forceMFA}
|
||||
onChange={(e) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
forceMFA: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t("workspace.people.addMember.role")}
|
||||
data={roleOptions}
|
||||
value={inviteForm.role}
|
||||
onChange={(value) =>
|
||||
setInviteForm({ ...inviteForm, role: value || "ROLE_USER" })
|
||||
}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("workspace.people.addMember.team")}
|
||||
placeholder={t("workspace.people.addMember.teamPlaceholder")}
|
||||
data={teamOptions}
|
||||
value={inviteForm.teamId?.toString()}
|
||||
onChange={(value) =>
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
teamId: value ? parseInt(value) : undefined,
|
||||
})
|
||||
}
|
||||
clearable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
loading={!hasNoSlots && processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{inviteMode === "email"
|
||||
? t("workspace.people.emailInvite.submit", "Send Invites")
|
||||
: inviteMode === "link"
|
||||
? t("workspace.people.inviteLink.submit", "Generate Link")
|
||||
: t("workspace.people.addMember.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
|
||||
type ImageSlide = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
cornerModelUrl?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
followMouseTilt?: boolean;
|
||||
tiltMaxDeg?: number;
|
||||
};
|
||||
|
||||
function LoginRightCarousel({
|
||||
imageSlides = [],
|
||||
showBackground = true,
|
||||
initialSeconds = 5,
|
||||
slideSeconds = 8,
|
||||
}: {
|
||||
imageSlides?: ImageSlide[];
|
||||
showBackground?: boolean;
|
||||
initialSeconds?: number;
|
||||
slideSeconds?: number;
|
||||
}) {
|
||||
const totalSlides = imageSlides.length;
|
||||
const [index, setIndex] = useState(0);
|
||||
const mouse = useRef({ x: 0, y: 0 });
|
||||
|
||||
const durationsMs = useMemo(() => {
|
||||
if (imageSlides.length === 0) return [];
|
||||
return imageSlides.map(
|
||||
(_, i) =>
|
||||
(i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000,
|
||||
);
|
||||
}, [imageSlides, initialSeconds, slideSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalSlides <= 1) return;
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
setIndex((i) => (i + 1) % totalSlides);
|
||||
},
|
||||
durationsMs[index] ?? slideSeconds * 1000,
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [index, totalSlides, durationsMs, slideSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
return () => window.removeEventListener("mousemove", onMove);
|
||||
}, []);
|
||||
|
||||
function TiltImage({
|
||||
src,
|
||||
alt,
|
||||
enabled,
|
||||
maxDeg = 6,
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
enabled: boolean;
|
||||
maxDeg?: number;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
if (enabled) {
|
||||
const rotY = (mouse.current.x || 0) * maxDeg;
|
||||
const rotX = -(mouse.current.y || 0) * maxDeg;
|
||||
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
|
||||
} else {
|
||||
el.style.transform = "translateY(-2rem)";
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [enabled, maxDeg]);
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt ?? "Carousel slide"}
|
||||
style={{
|
||||
maxWidth: "86%",
|
||||
maxHeight: "78%",
|
||||
objectFit: "contain",
|
||||
borderRadius: "18px",
|
||||
background: "transparent",
|
||||
transform: "translateY(-2rem)",
|
||||
transition: "transform 80ms ease-out",
|
||||
willChange: "transform",
|
||||
transformOrigin: "50% 50%",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{showBackground && (
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
|
||||
alt="Background panel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image slides */}
|
||||
{imageSlides.map((s, idx) => (
|
||||
<div
|
||||
key={s.src}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "opacity 600ms ease",
|
||||
opacity: index === idx ? 1 : 0,
|
||||
perspective: "900px",
|
||||
}}
|
||||
>
|
||||
{(s.title || s.subtitle) && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 24 + 32,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
padding: "0 2rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{s.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
textShadow: "0 2px 6px rgba(0,0,0,0.25)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{s.title}
|
||||
</div>
|
||||
)}
|
||||
{s.subtitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "rgba(255,255,255,0.92)",
|
||||
textShadow: "0 1px 4px rgba(0,0,0,0.25)",
|
||||
}}
|
||||
>
|
||||
{s.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TiltImage
|
||||
src={s.src}
|
||||
alt={s.alt}
|
||||
enabled={index === idx && !!s.followMouseTilt}
|
||||
maxDeg={s.tiltMaxDeg ?? 6}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Dot navigation */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalSlides }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
style={{
|
||||
width: "10px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
i === index ? "#ffffff" : "rgba(255,255,255,0.5)",
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
|
||||
display: "block",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LoginRightCarousel);
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import licenseService from "@app/services/licenseService";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
interface ManageBillingButtonProps {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
|
||||
returnUrl = window.location.href,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get current license key for authentication
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
|
||||
if (!licenseInfo.licenseKey) {
|
||||
throw new Error(
|
||||
"No license key found. Please activate a license first.",
|
||||
);
|
||||
}
|
||||
|
||||
// Create billing portal session with license key
|
||||
const response = await licenseService.createBillingPortalSession(
|
||||
returnUrl,
|
||||
licenseInfo.licenseKey,
|
||||
);
|
||||
|
||||
// Open billing portal in new tab
|
||||
window.open(response.url, "_blank");
|
||||
setLoading(false);
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to open billing portal:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("billing.portal.error", "Failed to open billing portal"),
|
||||
body:
|
||||
(error instanceof Error ? error.message : undefined) ||
|
||||
"Please try again or contact support.",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick} loading={loading}>
|
||||
{t("billing.manageBilling", "Manage Billing")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { Button, ButtonProps } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext";
|
||||
|
||||
interface UpdateSeatsButtonProps extends Omit<
|
||||
ButtonProps,
|
||||
"onClick" | "loading"
|
||||
> {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...buttonProps
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { openUpdateSeats, isLoading } = useUpdateSeats();
|
||||
|
||||
const handleClick = async () => {
|
||||
await openUpdateSeats({
|
||||
onSuccess,
|
||||
onError,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClick}
|
||||
loading={isLoading}
|
||||
{...buttonProps}
|
||||
>
|
||||
{t("billing.updateSeats", "Update Seats")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateSeatsButton;
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
Loader,
|
||||
Stack,
|
||||
Group,
|
||||
NumberInput,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface UpdateSeatsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentSeats: number;
|
||||
minimumSeats: number;
|
||||
_onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onUpdateSeats?: (newSeats: number) => Promise<string>; // Returns billing portal URL
|
||||
}
|
||||
|
||||
type UpdateState = {
|
||||
status: "idle" | "loading" | "error";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentSeats,
|
||||
minimumSeats,
|
||||
_onSuccess,
|
||||
onError,
|
||||
onUpdateSeats,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [newSeatCount, setNewSeatCount] = useState<number>(minimumSeats);
|
||||
|
||||
// Reset seat count when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setNewSeatCount(minimumSeats);
|
||||
setState({ status: "idle" });
|
||||
}
|
||||
}, [opened, minimumSeats]);
|
||||
|
||||
const handleUpdateSeats = async () => {
|
||||
if (!onUpdateSeats) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: "Update function not provided",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSeatCount < minimumSeats) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: t(
|
||||
"billing.seatCountTooLow",
|
||||
"Seat count must be at least {{minimum}} (current number of users)",
|
||||
{
|
||||
minimum: minimumSeats,
|
||||
},
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSeatCount === currentSeats) {
|
||||
setState({
|
||||
status: "error",
|
||||
error: t(
|
||||
"billing.seatCountUnchanged",
|
||||
"Please select a different seat count",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState({ status: "loading" });
|
||||
|
||||
// Call the update function (will call manage-billing)
|
||||
const portalUrl = await onUpdateSeats(newSeatCount);
|
||||
|
||||
// Redirect to Stripe billing portal
|
||||
console.log("Redirecting to Stripe billing portal:", portalUrl);
|
||||
window.location.href = portalUrl;
|
||||
|
||||
// Note: No need to call onSuccess here since we're redirecting
|
||||
// The return flow will handle success notification
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update seat count";
|
||||
setState({
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState({ status: "idle" });
|
||||
setNewSeatCount(currentSeats);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t("billing.preparingUpdate", "Preparing seat update...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{state.status === "error" && (
|
||||
<Alert color="red" title={t("common.error", "Error")}>
|
||||
{state.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("billing.currentSeats", "Current Seats")}:
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{currentSeats}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("billing.minimumSeats", "Minimum Seats")}:
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{minimumSeats} {t("billing.basedOnUsers", "(current users)")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<NumberInput
|
||||
label={t("billing.newSeatCount", "New Seat Count")}
|
||||
description={t(
|
||||
"billing.newSeatCountDescription",
|
||||
"Select the number of seats for your enterprise license",
|
||||
)}
|
||||
value={newSeatCount}
|
||||
onChange={(value) =>
|
||||
setNewSeatCount(typeof value === "number" ? value : minimumSeats)
|
||||
}
|
||||
min={minimumSeats}
|
||||
max={10000}
|
||||
step={1}
|
||||
size="md"
|
||||
styles={{
|
||||
input: {
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 500,
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t("billing.whatHappensNext", "What happens next?")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"billing.stripePortalRedirect",
|
||||
"You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateSeats}
|
||||
disabled={
|
||||
newSeatCount === currentSeats || newSeatCount < minimumSeats
|
||||
}
|
||||
>
|
||||
{t("billing.updateSeats", "Update Seats")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t("billing.updateEnterpriseSeats", "Update Enterprise Seats")}
|
||||
</Text>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateSeatsModal;
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { isAuthRoute } from "@core/constants/routes";
|
||||
import { useCheckout } from "@app/contexts/CheckoutContext";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import {
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
UPGRADE_BANNER_TEST_EVENT,
|
||||
type UpgradeBannerTestPayload,
|
||||
type UpgradeBannerTestScenario,
|
||||
UPGRADE_BANNER_ALERT_EVENT,
|
||||
} from "@core/constants/events";
|
||||
import { useServerExperience } from "@app/hooks/useServerExperience";
|
||||
import { isOnboardingCompleted } from "@core/components/onboarding/orchestrator/onboardingStorage";
|
||||
|
||||
const FRIENDLY_LAST_SEEN_KEY = "upgradeBannerFriendlyLastShownAt";
|
||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Check if we're on an auth route (evaluated after hooks, used in render)
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { openCheckout } = useCheckout();
|
||||
const {
|
||||
loginEnabled,
|
||||
totalUsers,
|
||||
userCountResolved,
|
||||
userCountLoading,
|
||||
effectiveIsAdmin: configIsAdmin,
|
||||
hasPaidLicense,
|
||||
licenseLoading,
|
||||
freeTierLimit,
|
||||
overFreeTierLimit,
|
||||
weeklyActiveUsers,
|
||||
scenarioKey,
|
||||
} = useServerExperience();
|
||||
const onboardingComplete = isOnboardingCompleted();
|
||||
const [friendlyVisible, setFriendlyVisible] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
if (!lastShownRaw) return false;
|
||||
const lastShown = parseInt(lastShownRaw, 10);
|
||||
if (!Number.isFinite(lastShown)) return false;
|
||||
return Date.now() - lastShown >= WEEK_IN_MS;
|
||||
});
|
||||
const isDev = import.meta.env.DEV;
|
||||
const [testScenario, setTestScenario] =
|
||||
useState<UpgradeBannerTestScenario>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDev || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTestEvent = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
|
||||
setTestScenario(detail?.scenario ?? null);
|
||||
|
||||
if (detail?.scenario === "friendly") {
|
||||
setFriendlyVisible(true);
|
||||
} else if (!detail?.scenario) {
|
||||
setFriendlyVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
UPGRADE_BANNER_TEST_EVENT,
|
||||
handleTestEvent as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
UPGRADE_BANNER_TEST_EVENT,
|
||||
handleTestEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [isDev]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
|
||||
const scenario = isDev ? testScenario : null;
|
||||
const scenarioIsFriendly = scenario === "friendly";
|
||||
const scenarioIsUrgentUser = scenario === "urgent-user";
|
||||
|
||||
const userCountKnown = typeof totalUsers === "number";
|
||||
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
|
||||
const isOverLimit = userCountKnown
|
||||
? totalUsers > freeTierLimit
|
||||
: overFreeTierLimit;
|
||||
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== "unknown" && scenarioKey !== "licensed";
|
||||
const derivedIsAdmin = scenarioProvidesInfo
|
||||
? scenarioKey.includes("admin")
|
||||
: isAdmin;
|
||||
const derivedHasPaidLicense =
|
||||
scenarioKey === "licensed"
|
||||
? true
|
||||
: scenarioKey === "unknown"
|
||||
? hasPaidLicense
|
||||
: false;
|
||||
const derivedIsUnderLimit = scenarioProvidesInfo
|
||||
? scenarioKey.includes("under-limit")
|
||||
: isUnderLimit === true;
|
||||
const derivedIsOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey.includes("over-limit")
|
||||
: isOverLimit === true;
|
||||
|
||||
const effectiveIsAdmin = scenario
|
||||
? scenarioIsUrgentUser
|
||||
? false
|
||||
: true
|
||||
: derivedIsAdmin;
|
||||
const effectiveTotalUsers =
|
||||
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
|
||||
const effectiveTotalUsersLoaded =
|
||||
scenario != null ? true : baseTotalUsersLoaded;
|
||||
const effectiveHasPaidLicense =
|
||||
scenario != null ? false : derivedHasPaidLicense;
|
||||
const effectiveIsUnderLimit =
|
||||
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
|
||||
const effectiveIsOverLimit =
|
||||
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
|
||||
|
||||
const isDerivedAdmin = scenario
|
||||
? !scenarioIsUrgentUser
|
||||
: scenarioKey === "login-user-over-limit-no-license"
|
||||
? false
|
||||
: effectiveIsAdmin;
|
||||
|
||||
const shouldShowFriendlyBase = Boolean(
|
||||
isDerivedAdmin &&
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveIsUnderLimit &&
|
||||
effectiveTotalUsersLoaded,
|
||||
);
|
||||
const shouldShowUrgentBase = Boolean(
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
(effectiveIsOverLimit ||
|
||||
scenarioKey === "login-user-over-limit-no-license"),
|
||||
);
|
||||
|
||||
const shouldEvaluateFriendly = scenario
|
||||
? scenarioIsFriendly
|
||||
: Boolean(
|
||||
shouldShowFriendlyBase &&
|
||||
!licenseLoading &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
onboardingComplete,
|
||||
);
|
||||
// Urgent banner should always show when over-limit
|
||||
const shouldEvaluateUrgent = scenario
|
||||
? Boolean(scenario && !scenarioIsFriendly)
|
||||
: Boolean(shouldShowUrgentBase && !licenseLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (scenario === "friendly") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldEvaluateFriendly) {
|
||||
setFriendlyVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (friendlyVisible || typeof window === "undefined" || userCountLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
|
||||
const now = Date.now();
|
||||
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
|
||||
setFriendlyVisible(due);
|
||||
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = shouldEvaluateUrgent
|
||||
? {
|
||||
active: true,
|
||||
audience: effectiveIsAdmin ? "admin" : "user",
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
}
|
||||
: { active: false };
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
|
||||
);
|
||||
}, [
|
||||
shouldEvaluateUrgent,
|
||||
effectiveIsAdmin,
|
||||
effectiveTotalUsers,
|
||||
scenario,
|
||||
freeTierLimit,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, {
|
||||
detail: { active: false },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recordFriendlyLastShown = useCallback(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
|
||||
}, []);
|
||||
|
||||
const handleUpgrade = () => {
|
||||
recordFriendlyLastShown();
|
||||
|
||||
const hideBanner = () => setFriendlyVisible(false);
|
||||
const navigateFallback = () => {
|
||||
navigate("/settings/adminPlan");
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
try {
|
||||
openCheckout("server", {
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
hideBanner();
|
||||
},
|
||||
onError: () => {
|
||||
navigateFallback();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[UpgradeBanner] Failed to open checkout, redirecting instead",
|
||||
error,
|
||||
);
|
||||
navigateFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep legacy behavior so banner disappears once the user initiates checkout
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
const handleFriendlyDismiss = () => {
|
||||
recordFriendlyLastShown();
|
||||
setFriendlyVisible(false);
|
||||
};
|
||||
|
||||
const handleSeeInfo = () => {
|
||||
if (typeof window === "undefined" || !effectiveIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail: ServerLicenseRequestPayload = {
|
||||
licenseNotice: {
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
isOverLimit: effectiveIsOverLimit ?? false,
|
||||
},
|
||||
selfReportedAdmin: true,
|
||||
deferUntilTourComplete: false,
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
|
||||
);
|
||||
};
|
||||
|
||||
const renderUrgentBanner = () => {
|
||||
if (!shouldEvaluateUrgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonText = effectiveIsAdmin
|
||||
? t("upgradeBanner.seeInfo", "See info")
|
||||
: undefined;
|
||||
|
||||
const attentionMessage = effectiveIsAdmin
|
||||
? t(
|
||||
"upgradeBanner.attentionBodyAdmin",
|
||||
"Review the license requirements to keep this server compliant.",
|
||||
)
|
||||
: t(
|
||||
"upgradeBanner.attentionBody",
|
||||
"Your admin needs to sign in to see more info. Please contact them immediately.",
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
"upgradeBanner.attentionTitle",
|
||||
"This server needs admin attention",
|
||||
)}
|
||||
message={attentionMessage}
|
||||
buttonText={buttonText}
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={buttonText ? handleSeeInfo : undefined}
|
||||
dismissible={false}
|
||||
minHeight={60}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const suppressForNoLogin =
|
||||
!loginEnabled ||
|
||||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
|
||||
|
||||
// Don't show on auth routes or if neither banner type should show
|
||||
// Also suppress entirely for no-login servers (treat them as regular users only)
|
||||
// and, per request, never surface upgrade messaging there when WAU > 5.
|
||||
if (
|
||||
onAuthRoute ||
|
||||
suppressForNoLogin ||
|
||||
(!friendlyVisible && !shouldEvaluateUrgent)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{friendlyVisible && (
|
||||
<InfoBanner
|
||||
icon="stars-rounded"
|
||||
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
|
||||
message={t(
|
||||
"upgradeBanner.message",
|
||||
"Get the most out of Stirling PDF with unlimited users and advanced features.",
|
||||
)}
|
||||
buttonText={t("upgradeBanner.upgradeButton", "Upgrade Now")}
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={handleUpgrade}
|
||||
onDismiss={handleFriendlyDismiss}
|
||||
show={friendlyVisible}
|
||||
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
borderColor="transparent"
|
||||
textColor="#fff"
|
||||
iconColor="#fff"
|
||||
closeIconColor="#fff"
|
||||
buttonVariant="white"
|
||||
buttonColor="blue"
|
||||
minHeight={48}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{renderUrgentBanner()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeBanner;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||
|
||||
export function UpgradeBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
|
||||
useEffect(() => {
|
||||
setBanner(<UpgradeBanner />);
|
||||
return () => {
|
||||
setBanner(null);
|
||||
};
|
||||
}, [setBanner]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface EnterpriseRequiredBannerProps {
|
||||
show: boolean;
|
||||
featureName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner that explains enterprise-only features are in demo mode
|
||||
*/
|
||||
export default function EnterpriseRequiredBanner({
|
||||
show,
|
||||
featureName,
|
||||
}: EnterpriseRequiredBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="workspace-premium-rounded" width={20} height={20} />
|
||||
}
|
||||
title={t(
|
||||
"admin.settings.enterpriseRequired.title",
|
||||
"Enterprise License Required",
|
||||
)}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderLeft: "4px solid var(--mantine-color-yellow-6)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"admin.settings.enterpriseRequired.message",
|
||||
"An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference.",
|
||||
{ featureName },
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function OverviewHeader() {
|
||||
const { t } = useTranslation();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate("/login");
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("config.overview.title", "Application Configuration")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"config.overview.description",
|
||||
"Current application settings and configuration details.",
|
||||
)}
|
||||
</Text>
|
||||
{user?.email && (
|
||||
<Text size="xs" c="dimmed" mt="0.25rem">
|
||||
Signed in as: {user.email}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<Button color="red" variant="filled" onClick={handleLogout}>
|
||||
Log out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useConfigNavSections as useCoreConfigNavSections,
|
||||
createConfigNavSections as createCoreConfigNavSections,
|
||||
ConfigNavSection,
|
||||
} from "@core/components/shared/config/configNavSections";
|
||||
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
|
||||
import TeamsSection from "@app/components/shared/config/configSections/TeamsSection";
|
||||
import AdminGeneralSection from "@app/components/shared/config/configSections/AdminGeneralSection";
|
||||
import AdminSecuritySection from "@app/components/shared/config/configSections/AdminSecuritySection";
|
||||
import AdminConnectionsSection from "@app/components/shared/config/configSections/AdminConnectionsSection";
|
||||
import AdminPrivacySection from "@app/components/shared/config/configSections/AdminPrivacySection";
|
||||
import AdminDatabaseSection from "@app/components/shared/config/configSections/AdminDatabaseSection";
|
||||
import AdminAdvancedSection from "@app/components/shared/config/configSections/AdminAdvancedSection";
|
||||
import AdminLegalSection from "@app/components/shared/config/configSections/AdminLegalSection";
|
||||
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
|
||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import AccountSection from "@app/components/shared/config/configSections/AccountSection";
|
||||
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
|
||||
|
||||
/**
|
||||
* Hook version of proprietary config nav sections with proper i18n support
|
||||
*/
|
||||
export const useConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
onRequestClose: () => void = () => {},
|
||||
): ConfigNavSection[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get the core sections (Preferences + Help)
|
||||
const sections = useCoreConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
onRequestClose,
|
||||
);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "general"),
|
||||
);
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === "general"
|
||||
? { ...item, component: <GeneralSection /> }
|
||||
: item,
|
||||
);
|
||||
|
||||
if (loginEnabled) {
|
||||
preferencesSection.items.push({
|
||||
key: "account",
|
||||
label: t("account.accountSettings", "Account"),
|
||||
icon: "person-rounded",
|
||||
component: <AccountSection />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
|
||||
if (isAdmin || !loginEnabled) {
|
||||
const requiresLogin = !loginEnabled;
|
||||
const enableLoginTooltip = t(
|
||||
"settings.tooltips.enableLoginFirst",
|
||||
"Enable login mode first",
|
||||
);
|
||||
const requiresEnterpriseTooltip = t(
|
||||
"settings.tooltips.requiresEnterprise",
|
||||
"Requires Enterprise license",
|
||||
);
|
||||
|
||||
// Workspace
|
||||
sections.push({
|
||||
title: t("settings.workspace.title", "Workspace"),
|
||||
items: [
|
||||
{
|
||||
key: "people",
|
||||
label: t("settings.workspace.people", "People"),
|
||||
icon: "group-rounded",
|
||||
component: <PeopleSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
label: t("settings.workspace.teams", "Teams"),
|
||||
icon: "groups-rounded",
|
||||
component: <TeamsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: t("settings.configuration.title", "Configuration"),
|
||||
items: [
|
||||
{
|
||||
key: "adminGeneral",
|
||||
label: t("settings.configuration.systemSettings", "System Settings"),
|
||||
icon: "settings-rounded",
|
||||
component: <AdminGeneralSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminFeatures",
|
||||
label: t("settings.configuration.features", "Features"),
|
||||
icon: "extension-rounded",
|
||||
component: <AdminFeaturesSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminStorageSharing",
|
||||
label: t(
|
||||
"settings.configuration.storageSharing",
|
||||
"File Storage & Sharing",
|
||||
),
|
||||
icon: "storage-rounded",
|
||||
component: <AdminStorageSharingSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
badge: t("toolPanel.alpha", "Alpha"),
|
||||
badgeColor: "orange",
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
label: t("settings.configuration.endpoints", "Endpoints"),
|
||||
icon: "api-rounded",
|
||||
component: <AdminEndpointsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
label: t("settings.configuration.database", "Database"),
|
||||
icon: "storage-rounded",
|
||||
component: <AdminDatabaseSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAdvanced",
|
||||
label: t("settings.configuration.advanced", "Advanced"),
|
||||
icon: "tune-rounded",
|
||||
component: <AdminAdvancedSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: t("settings.securityAuth.title", "Security & Authentication"),
|
||||
items: [
|
||||
{
|
||||
key: "adminSecurity",
|
||||
label: t("settings.securityAuth.security", "Security"),
|
||||
icon: "shield-rounded",
|
||||
component: <AdminSecuritySection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminConnections",
|
||||
label: t("settings.securityAuth.connections", "Connections"),
|
||||
icon: "link-rounded",
|
||||
component: <AdminConnectionsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: t("settings.licensingAnalytics.title", "Licensing & Analytics"),
|
||||
items: [
|
||||
{
|
||||
key: "adminPlan",
|
||||
label: t("settings.licensingAnalytics.plan", "Plan"),
|
||||
icon: "star-rounded",
|
||||
component: <AdminPlanSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAudit",
|
||||
label: t("settings.licensingAnalytics.audit", "Audit"),
|
||||
icon: "fact-check-rounded",
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? enableLoginTooltip
|
||||
: requiresEnterpriseTooltip,
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
label: t(
|
||||
"settings.licensingAnalytics.usageAnalytics",
|
||||
"Usage Analytics",
|
||||
),
|
||||
icon: "analytics-rounded",
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? enableLoginTooltip
|
||||
: requiresEnterpriseTooltip,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: t("settings.policiesPrivacy.title", "Policies & Privacy"),
|
||||
items: [
|
||||
{
|
||||
key: "adminLegal",
|
||||
label: t("settings.policiesPrivacy.legal", "Legal"),
|
||||
icon: "gavel-rounded",
|
||||
component: <AdminLegalSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminPrivacy",
|
||||
label: t("settings.policiesPrivacy.privacy", "Privacy"),
|
||||
icon: "visibility-rounded",
|
||||
component: <AdminPrivacySection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Developer section if login is enabled
|
||||
if (loginEnabled) {
|
||||
const developerSection: ConfigNavSection = {
|
||||
title: t("settings.developer.title", "Developer"),
|
||||
items: [
|
||||
{
|
||||
key: "api-keys",
|
||||
label: t("settings.developer.apiKeys", "API Keys"),
|
||||
icon: "key-rounded",
|
||||
component: <ApiKeys />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Add Developer section after Preferences (or Workspace if it exists)
|
||||
const insertIndex = isAdmin ? 2 : 1;
|
||||
sections.splice(insertIndex, 0, developerSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated: Use useConfigNavSections hook instead
|
||||
* Proprietary extension of createConfigNavSections that adds all admin and workspace sections
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
loginEnabled: boolean = false,
|
||||
): ConfigNavSection[] => {
|
||||
console.warn(
|
||||
"createConfigNavSections is deprecated. Use useConfigNavSections hook instead for proper i18n support.",
|
||||
);
|
||||
|
||||
// Get the core sections (just Preferences)
|
||||
const sections = createCoreConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
// Add account management under Preferences
|
||||
const preferencesSection = sections.find((section) =>
|
||||
section.items.some((item) => item.key === "general"),
|
||||
);
|
||||
if (preferencesSection) {
|
||||
preferencesSection.items = preferencesSection.items.map((item) =>
|
||||
item.key === "general"
|
||||
? { ...item, component: <GeneralSection /> }
|
||||
: item,
|
||||
);
|
||||
|
||||
if (loginEnabled) {
|
||||
preferencesSection.items.push({
|
||||
key: "account",
|
||||
label: "Account",
|
||||
icon: "person-rounded",
|
||||
component: <AccountSection />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
|
||||
if (isAdmin || !loginEnabled) {
|
||||
const requiresLogin = !loginEnabled;
|
||||
|
||||
// Workspace
|
||||
sections.push({
|
||||
title: "Workspace",
|
||||
items: [
|
||||
{
|
||||
key: "people",
|
||||
label: "People",
|
||||
icon: "group-rounded",
|
||||
component: <PeopleSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
label: "Teams",
|
||||
icon: "groups-rounded",
|
||||
component: <TeamsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: "Configuration",
|
||||
items: [
|
||||
{
|
||||
key: "adminGeneral",
|
||||
label: "System Settings",
|
||||
icon: "settings-rounded",
|
||||
component: <AdminGeneralSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminFeatures",
|
||||
label: "Features",
|
||||
icon: "extension-rounded",
|
||||
component: <AdminFeaturesSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminEndpoints",
|
||||
label: "Endpoints",
|
||||
icon: "api-rounded",
|
||||
component: <AdminEndpointsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
label: "Database",
|
||||
icon: "storage-rounded",
|
||||
component: <AdminDatabaseSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAdvanced",
|
||||
label: "Advanced",
|
||||
icon: "tune-rounded",
|
||||
component: <AdminAdvancedSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: "Security & Authentication",
|
||||
items: [
|
||||
{
|
||||
key: "adminSecurity",
|
||||
label: "Security",
|
||||
icon: "shield-rounded",
|
||||
component: <AdminSecuritySection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminConnections",
|
||||
label: "Connections",
|
||||
icon: "link-rounded",
|
||||
component: <AdminConnectionsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: "Licensing & Analytics",
|
||||
items: [
|
||||
{
|
||||
key: "adminPlan",
|
||||
label: "Plan",
|
||||
icon: "star-rounded",
|
||||
component: <AdminPlanSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAudit",
|
||||
label: "Audit",
|
||||
icon: "fact-check-rounded",
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: "Requires Enterprise license",
|
||||
},
|
||||
{
|
||||
key: "adminUsage",
|
||||
label: "Usage Analytics",
|
||||
icon: "analytics-rounded",
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE || requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: "Requires Enterprise license",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: "Policies & Privacy",
|
||||
items: [
|
||||
{
|
||||
key: "adminLegal",
|
||||
label: "Legal",
|
||||
icon: "gavel-rounded",
|
||||
component: <AdminLegalSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
key: "adminPrivacy",
|
||||
label: "Privacy",
|
||||
icon: "visibility-rounded",
|
||||
component: <AdminPrivacySection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin
|
||||
? "Enable login mode first"
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Add Developer section if login is enabled
|
||||
if (loginEnabled) {
|
||||
const developerSection: ConfigNavSection = {
|
||||
title: "Developer",
|
||||
items: [
|
||||
{
|
||||
key: "api-keys",
|
||||
label: "API Keys",
|
||||
icon: "key-rounded",
|
||||
component: <ApiKeys />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Add Developer section after Preferences (or Workspace if it exists)
|
||||
const insertIndex = isAdmin ? 2 : 1;
|
||||
sections.splice(insertIndex, 0, developerSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
ConfigNavSection,
|
||||
ConfigNavItem,
|
||||
ConfigColors,
|
||||
} from "@core/components/shared/config/configNavSections";
|
||||
+783
@@ -0,0 +1,783 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Box,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert as showToast } from "@app/components/toast";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { accountService } from "@app/services/accountService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useAccountLogout } from "@app/extensions/accountLogout";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, signOut } = useAuth();
|
||||
const accountLogout = useAccountLogout();
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||
|
||||
const [currentPasswordForUsername, setCurrentPasswordForUsername] =
|
||||
useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [usernameError, setUsernameError] = useState("");
|
||||
const [usernameSubmitting, setUsernameSubmitting] = useState(false);
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [mfaSetupModalOpen, setMfaSetupModalOpen] = useState(false);
|
||||
const [mfaDisableModalOpen, setMfaDisableModalOpen] = useState(false);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState("");
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState("");
|
||||
const [mfaError, setMfaError] = useState("");
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
const [changeButtonDisabled, setChangeButtonDisabled] = useState(false);
|
||||
const normalizeMfaCode = useCallback(
|
||||
(value: string) => value.replace(/\D/g, "").slice(0, 6),
|
||||
[],
|
||||
);
|
||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||
|
||||
const authTypeFromMetadata = useMemo(() => {
|
||||
const metadata = user?.app_metadata as
|
||||
| { authType?: string; authenticationType?: string }
|
||||
| undefined;
|
||||
return metadata?.authenticationType ?? metadata?.authType;
|
||||
}, [user?.app_metadata]);
|
||||
|
||||
const normalizedAuthType = useMemo(
|
||||
() =>
|
||||
(user?.authenticationType ?? authTypeFromMetadata ?? "").toLowerCase(),
|
||||
[authTypeFromMetadata, user?.authenticationType],
|
||||
);
|
||||
const isSsoUser = useMemo(
|
||||
() => ["sso", "oauth2", "saml2"].includes(normalizedAuthType),
|
||||
[normalizedAuthType],
|
||||
);
|
||||
|
||||
const userIdentifier = useMemo(
|
||||
() => user?.email || user?.username || "",
|
||||
[user?.email, user?.username],
|
||||
);
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
window.location.assign("/login");
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await accountLogout({ signOut, redirectToLogin });
|
||||
}, [accountLogout, redirectToLogin, signOut]);
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSsoUser) {
|
||||
setPasswordError(
|
||||
t(
|
||||
"settings.security.password.ssoDisabled",
|
||||
"Password changes are managed by your identity provider.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordError(
|
||||
t("settings.security.password.required", "All fields are required."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError(
|
||||
t("settings.security.password.mismatch", "New passwords do not match."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPasswordSubmitting(true);
|
||||
setPasswordError("");
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"settings.security.password.success",
|
||||
"Password updated successfully. Please sign in again.",
|
||||
),
|
||||
});
|
||||
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordModalOpen(false);
|
||||
await handleLogout();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setPasswordError(
|
||||
axiosError.response?.data?.message ||
|
||||
t(
|
||||
"settings.security.password.error",
|
||||
"Unable to update password. Please verify your current password and try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPasswordSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAccountData = async () => {
|
||||
setChangeButtonDisabled(true);
|
||||
try {
|
||||
const data = await accountService
|
||||
.getAccountData()
|
||||
.then((data) => data)
|
||||
.finally(() => {
|
||||
setChangeButtonDisabled(false);
|
||||
});
|
||||
setMfaEnabled(data.mfaEnabled ?? false);
|
||||
} catch {
|
||||
// ignore fetch errors for account data
|
||||
console.warn("Failed to fetch account data");
|
||||
} finally {
|
||||
setChangeButtonDisabled(false);
|
||||
}
|
||||
};
|
||||
void fetchAccountData();
|
||||
}, []);
|
||||
|
||||
const handleStartMfaSetup = useCallback(async () => {
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError("");
|
||||
setMfaSetupCode("");
|
||||
const data = await accountService.requestMfaSetup();
|
||||
setMfaSetupData(data);
|
||||
setMfaSetupModalOpen(true);
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t(
|
||||
"account.mfa.setupFailed",
|
||||
"Unable to start two-factor setup. Please try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleEnableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaSetupCode.trim()) {
|
||||
setMfaError(
|
||||
t(
|
||||
"account.mfa.codeRequired",
|
||||
"Enter the authentication code to continue.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError("");
|
||||
await accountService.enableMfa(mfaSetupCode.trim());
|
||||
setMfaEnabled(true);
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode("");
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t("account.mfa.enabled", "Two-factor authentication enabled."),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t(
|
||||
"account.mfa.enableFailed",
|
||||
"Unable to enable two-factor authentication. Check the code and try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaSetupCode, t],
|
||||
);
|
||||
|
||||
const handleDisableMfa = useCallback(
|
||||
async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!mfaDisableCode.trim()) {
|
||||
setMfaError(
|
||||
t(
|
||||
"account.mfa.codeRequired",
|
||||
"Enter the authentication code to continue.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMfaLoading(true);
|
||||
setMfaError("");
|
||||
await accountService.disableMfa(mfaDisableCode.trim());
|
||||
setMfaEnabled(false);
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode("");
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"account.mfa.disabled",
|
||||
"Two-factor authentication disabled.",
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
t(
|
||||
"account.mfa.disableFailed",
|
||||
"Unable to disable two-factor authentication. Check the code and try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
},
|
||||
[mfaDisableCode, t],
|
||||
);
|
||||
|
||||
const handleCloseMfaSetupModal = useCallback(async () => {
|
||||
setMfaSetupModalOpen(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaSetupCode("");
|
||||
setMfaError("");
|
||||
try {
|
||||
await accountService.cancelMfaSetup();
|
||||
} catch {
|
||||
console.warn("Failed to clear pending MFA setup");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseMfaDisableModal = useCallback(() => {
|
||||
setMfaDisableModalOpen(false);
|
||||
setMfaDisableCode("");
|
||||
setMfaError("");
|
||||
}, []);
|
||||
|
||||
const handleUsernameSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isSsoUser) {
|
||||
setUsernameError(
|
||||
t(
|
||||
"changeCreds.ssoManaged",
|
||||
"Your account is managed by your identity provider.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPasswordForUsername || !newUsername) {
|
||||
setUsernameError(
|
||||
t("settings.security.password.required", "All fields are required."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUsernameSubmitting(true);
|
||||
setUsernameError("");
|
||||
|
||||
await accountService.changeUsername(
|
||||
newUsername,
|
||||
currentPasswordForUsername,
|
||||
);
|
||||
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t("changeCreds.credsUpdated", "Account updated"),
|
||||
body: t(
|
||||
"changeCreds.description",
|
||||
"Changes saved. Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
setNewUsername("");
|
||||
setCurrentPasswordForUsername("");
|
||||
setUsernameModalOpen(false);
|
||||
await handleLogout();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setUsernameError(
|
||||
axiosError.response?.data?.message ||
|
||||
t(
|
||||
"changeCreds.error",
|
||||
"Unable to update username. Please verify your password and try again.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setUsernameSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("account.accountSettings", "Account")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("changeCreds.header", "Update Your Account Details")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{userIdentifier
|
||||
? t("settings.general.user", "User") + ": " + userIdentifier
|
||||
: t("account.accountSettings", "Account Settings")}
|
||||
</Text>
|
||||
|
||||
<Stack gap="xs">
|
||||
{isSsoUser && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"changeCreds.ssoManaged",
|
||||
"Your account is managed by your identity provider.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{!isSsoUser && (
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="key-rounded" />}
|
||||
onClick={() => setPasswordModalOpen(true)}
|
||||
>
|
||||
{t("settings.security.password.update", "Update password")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isSsoUser && (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="edit-rounded" />}
|
||||
onClick={() => setUsernameModalOpen(true)}
|
||||
>
|
||||
{t("account.changeUsername", "Change username")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="logout-rounded" />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t("settings.general.logout", "Log out")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>
|
||||
{t("account.mfa.title", "Two-factor authentication")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"account.mfa.description",
|
||||
"Add an extra layer of security to your account.",
|
||||
)}
|
||||
</Text>
|
||||
{isSsoUser ? (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1rem" height="1rem" />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"account.mfa.ssoManaged",
|
||||
"Two-factor authentication for this account is managed by your identity provider.",
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Group gap="sm" wrap="wrap">
|
||||
{!mfaEnabled ? (
|
||||
<Button
|
||||
leftSection={
|
||||
<LocalIcon icon="check-circle-outline-rounded" />
|
||||
}
|
||||
onClick={handleStartMfaSetup}
|
||||
loading={mfaLoading}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t(
|
||||
"account.mfa.enableButton",
|
||||
"Enable two-factor authentication",
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="close-rounded" />}
|
||||
onClick={() => {
|
||||
setMfaError("");
|
||||
setMfaDisableCode("");
|
||||
setMfaDisableModalOpen(true);
|
||||
}}
|
||||
disabled={changeButtonDisabled}
|
||||
>
|
||||
{t(
|
||||
"account.mfa.disableButton",
|
||||
"Disable two-factor authentication",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
title={t("settings.security.title", "Change password")}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"settings.security.password.subtitle",
|
||||
"Change your password. You will be logged out after updating.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{passwordError && (
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{passwordError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"settings.security.password.current",
|
||||
"Current password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"settings.security.password.currentPlaceholder",
|
||||
"Enter your current password",
|
||||
)}
|
||||
value={currentPassword}
|
||||
onChange={(event) =>
|
||||
setCurrentPassword(event.currentTarget.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("settings.security.password.new", "New password")}
|
||||
placeholder={t(
|
||||
"settings.security.password.newPlaceholder",
|
||||
"Enter a new password",
|
||||
)}
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"settings.security.password.confirm",
|
||||
"Confirm new password",
|
||||
)}
|
||||
placeholder={t(
|
||||
"settings.security.password.confirmPlaceholder",
|
||||
"Re-enter your new password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.currentTarget.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setPasswordModalOpen(false)}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={passwordSubmitting}
|
||||
leftSection={<LocalIcon icon="save-rounded" />}
|
||||
>
|
||||
{t("settings.security.password.update", "Update password")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaSetupModalOpen}
|
||||
onClose={handleCloseMfaSetupModal}
|
||||
title={t("account.mfa.setupTitle", "Set up two-factor authentication")}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleEnableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"account.mfa.setupDescription",
|
||||
"Scan the QR code with your authenticator app, then enter the 6-digit code to confirm.",
|
||||
)}
|
||||
</Text>
|
||||
{mfaSetupData && (
|
||||
<Stack gap="sm" align="center">
|
||||
<Box
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={mfaSetupData.otpauthUri || ""}
|
||||
size={180}
|
||||
level="H"
|
||||
imageSettings={{
|
||||
src: qrLogoSrc,
|
||||
height: 40,
|
||||
width: 40,
|
||||
excavate: true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("account.mfa.manualKey", "Manual setup key")}:{" "}
|
||||
<strong>{mfaSetupData.secret}</strong>
|
||||
</Text>
|
||||
<Text size="xs" c="orange">
|
||||
{t(
|
||||
"account.mfa.secretWarning",
|
||||
"Keep this key private. Anyone with access can generate valid authentication codes.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{mfaError && (
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t("account.mfa.codeLabel", "Authentication code")}
|
||||
placeholder={t(
|
||||
"account.mfa.codePlaceholder",
|
||||
"Enter 6-digit code",
|
||||
)}
|
||||
value={mfaSetupCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
|
||||
}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaSetupModal}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={mfaLoading}>
|
||||
{t("account.mfa.confirmEnable", "Enable")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={mfaDisableModalOpen}
|
||||
onClose={handleCloseMfaDisableModal}
|
||||
title={t(
|
||||
"account.mfa.disableTitle",
|
||||
"Disable two-factor authentication",
|
||||
)}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleDisableMfa}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"account.mfa.disableDescription",
|
||||
"Enter a valid authentication code to disable two-factor authentication.",
|
||||
)}
|
||||
</Text>
|
||||
{mfaError && (
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t("account.mfa.codeLabel", "Authentication code")}
|
||||
placeholder={t(
|
||||
"account.mfa.codePlaceholder",
|
||||
"Enter 6-digit code",
|
||||
)}
|
||||
value={mfaDisableCode}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMfaDisableCode(normalizeMfaCode(event.currentTarget.value))
|
||||
}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={handleCloseMfaDisableModal}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="red" loading={mfaLoading}>
|
||||
{t("account.mfa.confirmDisable", "Disable")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={usernameModalOpen}
|
||||
onClose={() => setUsernameModalOpen(false)}
|
||||
title={t("account.changeUsername", "Change username")}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<form onSubmit={handleUsernameSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"changeCreds.changeUsername",
|
||||
"Update your username. You will be logged out after updating.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{usernameError && (
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{usernameError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t("changeCreds.newUsername", "New Username")}
|
||||
placeholder={t("changeCreds.newUsername", "New Username")}
|
||||
value={newUsername}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNewUsername(event.currentTarget.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("changeCreds.oldPassword", "Current Password")}
|
||||
placeholder={t("changeCreds.oldPassword", "Current Password")}
|
||||
value={currentPasswordForUsername}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCurrentPasswordForUsername(event.currentTarget.value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setUsernameModalOpen(false)}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={usernameSubmitting}
|
||||
leftSection={<LocalIcon icon="save-rounded" />}
|
||||
>
|
||||
{t("common.save", "Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSection;
|
||||
+1790
File diff suppressed because it is too large
Load Diff
+283
@@ -0,0 +1,283 @@
|
||||
import React, { Suspense, lazy, useState, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Tabs,
|
||||
Loader,
|
||||
Alert,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Accordion,
|
||||
Center,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import auditService, {
|
||||
AuditSystemStatus as AuditStatus,
|
||||
} from "@app/services/auditService";
|
||||
import AuditSystemStatus from "@app/components/shared/config/configSections/audit/AuditSystemStatus";
|
||||
import AuditStatsCards from "@app/components/shared/config/configSections/audit/AuditStatsCards";
|
||||
|
||||
// AuditChartsSection pulls in recharts (~200 kB gzip). It's only rendered when
|
||||
// the user expands the "Events Over Time" accordion on this page, so we keep
|
||||
// the rest of the audit page eager and lazy-load just the charts.
|
||||
const AuditChartsSection = lazy(
|
||||
() =>
|
||||
import("@app/components/shared/config/configSections/audit/AuditChartsSection"),
|
||||
);
|
||||
import AuditEventsTable from "@app/components/shared/config/configSections/audit/AuditEventsTable";
|
||||
import AuditExportSection from "@app/components/shared/config/configSections/audit/AuditExportSection";
|
||||
import AuditClearDataSection from "@app/components/shared/config/configSections/audit/AuditClearDataSection";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
const AdminAuditSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const { config } = useAppConfig();
|
||||
const licenseType = config?.license ?? "NORMAL";
|
||||
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
|
||||
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
|
||||
const [systemStatus, setSystemStatus] = useState<AuditStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timePeriod, setTimePeriod] = useState<"day" | "week" | "month">(
|
||||
"week",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSystemStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await auditService.getSystemStatus();
|
||||
setSystemStatus(status);
|
||||
} catch (err: unknown) {
|
||||
// Check if this is a permission/license error (403/404)
|
||||
const status = isAxiosError(err) ? err.response?.status : undefined;
|
||||
if (status === 403 || status === 404) {
|
||||
setError("enterprise-license-required");
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load audit system status",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!showDemoData) {
|
||||
fetchSystemStatus();
|
||||
} else {
|
||||
// Provide example audit system status when running in demo mode
|
||||
setError(null);
|
||||
setSystemStatus({
|
||||
enabled: true,
|
||||
level: "INFO",
|
||||
retentionDays: 90,
|
||||
totalEvents: 1234,
|
||||
pdfMetadataEnabled: true,
|
||||
captureFileHash: true,
|
||||
capturePdfAuthor: true,
|
||||
captureOperationResults: false,
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loginEnabled, showDemoData]);
|
||||
|
||||
// Override loading state when showing demo data
|
||||
const actualLoading = showDemoData ? false : loading;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "2rem 0",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error === "enterprise-license-required") {
|
||||
return (
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t("audit.enterpriseRequired", "Enterprise License Required")}
|
||||
>
|
||||
{t(
|
||||
"audit.enterpriseRequiredMessage",
|
||||
"The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("audit.error.title", "Error loading audit system")}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemStatus) {
|
||||
return (
|
||||
<Alert
|
||||
color="yellow"
|
||||
title={t("audit.notAvailable", "Audit system not available")}
|
||||
>
|
||||
{t(
|
||||
"audit.notAvailableMessage",
|
||||
"The audit system is not configured or not available.",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = loginEnabled && hasEnterpriseLicense;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<EnterpriseRequiredBanner
|
||||
show={!hasEnterpriseLicense}
|
||||
featureName={t("settings.licensingAnalytics.audit", "Audit")}
|
||||
/>
|
||||
|
||||
{/* Info banner about audit settings */}
|
||||
{isEnabled && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||
title={t("audit.configureAudit", "Configure Audit Logging")}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"audit.configureAuditMessage",
|
||||
"Adjust audit logging level, retention period, and other settings in the Security & Authentication section.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
|
||||
rightSection={
|
||||
<LocalIcon
|
||||
icon="arrow-forward"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("audit.goToSettings", "Go to Audit Settings")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AuditSystemStatus status={systemStatus} />
|
||||
|
||||
{systemStatus?.enabled ? (
|
||||
<Tabs defaultValue="dashboard">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="dashboard" disabled={!isEnabled}>
|
||||
{t("audit.tabs.dashboard", "Dashboard")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="events" disabled={!isEnabled}>
|
||||
{t("audit.tabs.events", "Audit Events")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="export" disabled={!isEnabled}>
|
||||
{t("audit.tabs.export", "Export")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="clearData" disabled={!isEnabled}>
|
||||
{t("audit.tabs.clearData", "Clear Data")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="dashboard" pt="md">
|
||||
<Stack gap="lg">
|
||||
{/* Stats Cards - Always Visible */}
|
||||
<AuditStatsCards
|
||||
loginEnabled={isEnabled}
|
||||
timePeriod={timePeriod}
|
||||
/>
|
||||
|
||||
{/* Charts in Accordion - Collapsible */}
|
||||
<Accordion defaultValue={["events-over-time"]} multiple>
|
||||
<Accordion.Item value="events-over-time">
|
||||
<Accordion.Control>
|
||||
{t("audit.charts.overTime", "Events Over Time")}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center style={{ padding: "2rem" }}>
|
||||
<Loader />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<AuditChartsSection
|
||||
loginEnabled={isEnabled}
|
||||
timePeriod={timePeriod}
|
||||
onTimePeriodChange={setTimePeriod}
|
||||
/>
|
||||
</Suspense>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="events" pt="md">
|
||||
<AuditEventsTable
|
||||
loginEnabled={isEnabled}
|
||||
captureFileHash={systemStatus?.captureFileHash}
|
||||
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="export" pt="md">
|
||||
<AuditExportSection
|
||||
loginEnabled={isEnabled}
|
||||
captureFileHash={systemStatus?.captureFileHash}
|
||||
capturePdfAuthor={systemStatus?.capturePdfAuthor}
|
||||
captureOperationResults={systemStatus?.captureOperationResults}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="clearData" pt="md">
|
||||
<AuditClearDataSection loginEnabled={isEnabled} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t("audit.disabled", "Audit logging is disabled")}
|
||||
>
|
||||
{t(
|
||||
"audit.disabledMessage",
|
||||
"Enable audit logging in your application configuration to track system events.",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAuditSection;
|
||||
+995
@@ -0,0 +1,995 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Divider,
|
||||
Paper,
|
||||
Switch,
|
||||
Badge,
|
||||
Anchor,
|
||||
Select,
|
||||
Collapse,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { Z_INDEX_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import ProviderCard from "@app/components/shared/config/configSections/ProviderCard";
|
||||
import {
|
||||
Provider,
|
||||
useAllProviders,
|
||||
} from "@app/components/shared/config/configSections/providerDefinitions";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
interface FeedbackFlags {
|
||||
noValidDocument?: boolean;
|
||||
errorProcessing?: boolean;
|
||||
errorMessage?: boolean;
|
||||
}
|
||||
|
||||
interface FeedbackSettings {
|
||||
general?: { enabled?: boolean };
|
||||
channel?: FeedbackFlags;
|
||||
user?: FeedbackFlags;
|
||||
}
|
||||
|
||||
interface TelegramSettingsData {
|
||||
enabled?: boolean;
|
||||
botToken?: string;
|
||||
botUsername?: string;
|
||||
pipelineInboxFolder?: string;
|
||||
customFolderSuffix?: boolean;
|
||||
enableAllowUserIDs?: boolean;
|
||||
allowUserIDs?: number[];
|
||||
enableAllowChannelIDs?: boolean;
|
||||
allowChannelIDs?: number[];
|
||||
processingTimeoutSeconds?: number;
|
||||
pollingIntervalMillis?: number;
|
||||
feedback?: FeedbackSettings;
|
||||
}
|
||||
|
||||
interface MailSettings {
|
||||
enabled?: boolean;
|
||||
enableInvites?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
interface GoogleDriveSettings {
|
||||
enabled?: boolean;
|
||||
clientId?: string;
|
||||
apiKey?: string;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
interface OAuth2GenericSettings {
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
useAsUsername?: string;
|
||||
autoCreateUser?: boolean;
|
||||
blockRegistration?: boolean;
|
||||
}
|
||||
|
||||
interface Saml2Settings {
|
||||
enabled?: boolean;
|
||||
provider?: string;
|
||||
registrationId?: string;
|
||||
idpMetadataUri?: string;
|
||||
idpSingleLoginUrl?: string;
|
||||
idpSingleLogoutUrl?: string;
|
||||
idpIssuer?: string;
|
||||
idpCert?: string;
|
||||
privateKey?: string;
|
||||
spCert?: string;
|
||||
autoCreateUser?: boolean;
|
||||
blockRegistration?: boolean;
|
||||
}
|
||||
|
||||
interface OAuth2ClientSettings {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
useAsUsername?: string;
|
||||
issuer?: string;
|
||||
}
|
||||
|
||||
type ProviderSettings =
|
||||
| MailSettings
|
||||
| TelegramSettingsData
|
||||
| GoogleDriveSettings
|
||||
| OAuth2GenericSettings
|
||||
| Saml2Settings
|
||||
| OAuth2ClientSettings;
|
||||
|
||||
interface ConnectionsSettingsData {
|
||||
oauth2?: {
|
||||
enabled?: boolean;
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
provider?: string;
|
||||
autoCreateUser?: boolean;
|
||||
blockRegistration?: boolean;
|
||||
useAsUsername?: string;
|
||||
scopes?: string;
|
||||
client?: Record<string, OAuth2ClientSettings>;
|
||||
};
|
||||
saml2?: Saml2Settings;
|
||||
mail?: MailSettings;
|
||||
telegram?: TelegramSettingsData;
|
||||
ssoAutoLogin?: boolean;
|
||||
enableMobileScanner?: boolean;
|
||||
mobileScannerConvertToPdf?: boolean;
|
||||
mobileScannerImageResolution?: string;
|
||||
mobileScannerPageFormat?: string;
|
||||
mobileScannerStretchToFit?: boolean;
|
||||
googleDriveEnabled?: boolean;
|
||||
googleDriveClientId?: string;
|
||||
googleDriveApiKey?: string;
|
||||
googleDriveAppId?: string;
|
||||
}
|
||||
|
||||
export default function AdminConnectionsSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled, getDisabledStyles } = useLoginRequired();
|
||||
const { restartModalOpened, closeRestartModal, restartServer } =
|
||||
useRestartServer();
|
||||
const allProviders = useAllProviders();
|
||||
|
||||
const adminSettings = useAdminSettings<ConnectionsSettingsData>({
|
||||
sectionName: "connections",
|
||||
fetchTransformer: async (): Promise<
|
||||
ConnectionsSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
// Fetch security settings (oauth2, saml2)
|
||||
const securityResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/security",
|
||||
);
|
||||
const securityData = securityResponse.data || {};
|
||||
|
||||
// Fetch mail settings
|
||||
const mailResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/mail",
|
||||
);
|
||||
const mailData = mailResponse.data || {};
|
||||
|
||||
// Fetch premium settings for SSO Auto Login
|
||||
const premiumResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/premium",
|
||||
);
|
||||
const premiumData = premiumResponse.data || {};
|
||||
|
||||
// Fetch Telegram settings
|
||||
const telegramResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/telegram",
|
||||
);
|
||||
const telegramData = telegramResponse.data || {};
|
||||
|
||||
// Fetch system settings for enableMobileScanner
|
||||
const systemResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/system",
|
||||
);
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const result: ConnectionsSettingsData & {
|
||||
_pending?: Record<string, unknown>;
|
||||
} = {
|
||||
oauth2: securityData.oauth2 || {},
|
||||
saml2: securityData.saml2 || {},
|
||||
mail: mailData || {},
|
||||
telegram: telegramData || {},
|
||||
ssoAutoLogin: premiumData.proFeatures?.ssoAutoLogin || false,
|
||||
enableMobileScanner: systemData.enableMobileScanner || false,
|
||||
mobileScannerConvertToPdf:
|
||||
systemData.mobileScannerSettings?.convertToPdf !== false,
|
||||
mobileScannerImageResolution:
|
||||
systemData.mobileScannerSettings?.imageResolution || "full",
|
||||
mobileScannerPageFormat:
|
||||
systemData.mobileScannerSettings?.pageFormat || "A4",
|
||||
mobileScannerStretchToFit:
|
||||
systemData.mobileScannerSettings?.stretchToFit || false,
|
||||
googleDriveEnabled:
|
||||
premiumData.proFeatures?.googleDrive?.enabled || false,
|
||||
googleDriveClientId:
|
||||
premiumData.proFeatures?.googleDrive?.clientId || "",
|
||||
googleDriveApiKey: premiumData.proFeatures?.googleDrive?.apiKey || "",
|
||||
googleDriveAppId: premiumData.proFeatures?.googleDrive?.appId || "",
|
||||
};
|
||||
|
||||
// Merge pending blocks from all endpoints
|
||||
const pendingBlock: Record<string, unknown> = {};
|
||||
if (securityData._pending?.oauth2) {
|
||||
pendingBlock.oauth2 = securityData._pending.oauth2;
|
||||
}
|
||||
if (securityData._pending?.saml2) {
|
||||
pendingBlock.saml2 = securityData._pending.saml2;
|
||||
}
|
||||
if (mailData._pending) {
|
||||
pendingBlock.mail = mailData._pending;
|
||||
}
|
||||
if (telegramData._pending) {
|
||||
pendingBlock.telegram = telegramData._pending;
|
||||
}
|
||||
if (premiumData._pending?.proFeatures?.ssoAutoLogin !== undefined) {
|
||||
pendingBlock.ssoAutoLogin =
|
||||
premiumData._pending.proFeatures.ssoAutoLogin;
|
||||
}
|
||||
if (systemData._pending?.enableMobileScanner !== undefined) {
|
||||
pendingBlock.enableMobileScanner =
|
||||
systemData._pending.enableMobileScanner;
|
||||
}
|
||||
if (
|
||||
systemData._pending?.mobileScannerSettings?.convertToPdf !== undefined
|
||||
) {
|
||||
pendingBlock.mobileScannerConvertToPdf =
|
||||
systemData._pending.mobileScannerSettings.convertToPdf;
|
||||
}
|
||||
if (
|
||||
systemData._pending?.mobileScannerSettings?.imageResolution !==
|
||||
undefined
|
||||
) {
|
||||
pendingBlock.mobileScannerImageResolution =
|
||||
systemData._pending.mobileScannerSettings.imageResolution;
|
||||
}
|
||||
if (
|
||||
systemData._pending?.mobileScannerSettings?.pageFormat !== undefined
|
||||
) {
|
||||
pendingBlock.mobileScannerPageFormat =
|
||||
systemData._pending.mobileScannerSettings.pageFormat;
|
||||
}
|
||||
if (
|
||||
systemData._pending?.mobileScannerSettings?.stretchToFit !== undefined
|
||||
) {
|
||||
pendingBlock.mobileScannerStretchToFit =
|
||||
systemData._pending.mobileScannerSettings.stretchToFit;
|
||||
}
|
||||
if (
|
||||
premiumData._pending?.proFeatures?.googleDrive?.enabled !== undefined
|
||||
) {
|
||||
pendingBlock.googleDriveEnabled =
|
||||
premiumData._pending.proFeatures.googleDrive.enabled;
|
||||
}
|
||||
if (
|
||||
premiumData._pending?.proFeatures?.googleDrive?.clientId !== undefined
|
||||
) {
|
||||
pendingBlock.googleDriveClientId =
|
||||
premiumData._pending.proFeatures.googleDrive.clientId;
|
||||
}
|
||||
if (
|
||||
premiumData._pending?.proFeatures?.googleDrive?.apiKey !== undefined
|
||||
) {
|
||||
pendingBlock.googleDriveApiKey =
|
||||
premiumData._pending.proFeatures.googleDrive.apiKey;
|
||||
}
|
||||
if (premiumData._pending?.proFeatures?.googleDrive?.appId !== undefined) {
|
||||
pendingBlock.googleDriveAppId =
|
||||
premiumData._pending.proFeatures.googleDrive.appId;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (currentSettings: ConnectionsSettingsData) => {
|
||||
const deltaSettings: Record<string, unknown> = {};
|
||||
|
||||
// Build delta for oauth2 settings
|
||||
if (currentSettings.oauth2) {
|
||||
Object.keys(currentSettings.oauth2).forEach((key) => {
|
||||
if (key !== "client") {
|
||||
deltaSettings[`security.oauth2.${key}`] = (
|
||||
currentSettings.oauth2 as Record<string, unknown>
|
||||
)[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Build delta for specific OAuth2 providers
|
||||
const oauth2Client = currentSettings.oauth2.client;
|
||||
if (oauth2Client) {
|
||||
Object.keys(oauth2Client).forEach((providerId) => {
|
||||
const providerSettings = oauth2Client[providerId] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
Object.keys(providerSettings).forEach((key) => {
|
||||
deltaSettings[`security.oauth2.client.${providerId}.${key}`] =
|
||||
providerSettings[key];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build delta for saml2 settings
|
||||
if (currentSettings.saml2) {
|
||||
const saml2 = currentSettings.saml2 as Record<string, unknown>;
|
||||
Object.keys(saml2).forEach((key) => {
|
||||
deltaSettings[`security.saml2.${key}`] = saml2[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Mail settings
|
||||
if (currentSettings.mail) {
|
||||
const mail = currentSettings.mail as Record<string, unknown>;
|
||||
Object.keys(mail).forEach((key) => {
|
||||
deltaSettings[`mail.${key}`] = mail[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram settings
|
||||
if (currentSettings.telegram) {
|
||||
const telegram = currentSettings.telegram as Record<string, unknown>;
|
||||
Object.keys(telegram).forEach((key) => {
|
||||
deltaSettings[`telegram.${key}`] = telegram[key];
|
||||
});
|
||||
}
|
||||
|
||||
// SSO Auto Login
|
||||
if (currentSettings?.ssoAutoLogin !== undefined) {
|
||||
deltaSettings["premium.proFeatures.ssoAutoLogin"] =
|
||||
currentSettings.ssoAutoLogin;
|
||||
}
|
||||
|
||||
// Mobile Scanner settings
|
||||
if (currentSettings?.enableMobileScanner !== undefined) {
|
||||
deltaSettings["system.enableMobileScanner"] =
|
||||
currentSettings.enableMobileScanner;
|
||||
}
|
||||
if (currentSettings?.mobileScannerConvertToPdf !== undefined) {
|
||||
deltaSettings["system.mobileScannerSettings.convertToPdf"] =
|
||||
currentSettings.mobileScannerConvertToPdf;
|
||||
}
|
||||
if (currentSettings?.mobileScannerImageResolution !== undefined) {
|
||||
deltaSettings["system.mobileScannerSettings.imageResolution"] =
|
||||
currentSettings.mobileScannerImageResolution;
|
||||
}
|
||||
if (currentSettings?.mobileScannerPageFormat !== undefined) {
|
||||
deltaSettings["system.mobileScannerSettings.pageFormat"] =
|
||||
currentSettings.mobileScannerPageFormat;
|
||||
}
|
||||
if (currentSettings?.mobileScannerStretchToFit !== undefined) {
|
||||
deltaSettings["system.mobileScannerSettings.stretchToFit"] =
|
||||
currentSettings.mobileScannerStretchToFit;
|
||||
}
|
||||
|
||||
// Google Drive settings
|
||||
if (currentSettings?.googleDriveEnabled !== undefined) {
|
||||
deltaSettings["premium.proFeatures.googleDrive.enabled"] =
|
||||
currentSettings.googleDriveEnabled;
|
||||
}
|
||||
if (currentSettings?.googleDriveClientId !== undefined) {
|
||||
deltaSettings["premium.proFeatures.googleDrive.clientId"] =
|
||||
currentSettings.googleDriveClientId;
|
||||
}
|
||||
if (currentSettings?.googleDriveApiKey !== undefined) {
|
||||
deltaSettings["premium.proFeatures.googleDrive.apiKey"] =
|
||||
currentSettings.googleDriveApiKey;
|
||||
}
|
||||
if (currentSettings?.googleDriveAppId !== undefined) {
|
||||
deltaSettings["premium.proFeatures.googleDrive.appId"] =
|
||||
currentSettings.googleDriveAppId;
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { settings, setSettings, loading, fetchSettings, isFieldPending } =
|
||||
adminSettings;
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
markSaved();
|
||||
try {
|
||||
await adminSettings.saveSettings();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
const isProviderConfigured = (provider: Provider): boolean => {
|
||||
if (provider.id === "saml2") {
|
||||
return settings?.saml2?.enabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === "smtp") {
|
||||
return settings?.mail?.enabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === "telegram") {
|
||||
return settings?.telegram?.enabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === "googledrive") {
|
||||
return settings?.googleDriveEnabled === true;
|
||||
}
|
||||
|
||||
if (provider.id === "oauth2-generic") {
|
||||
return settings?.oauth2?.enabled === true;
|
||||
}
|
||||
|
||||
// Check if specific OAuth2 provider is configured (has clientId)
|
||||
const providerSettings = settings?.oauth2?.client?.[provider.id];
|
||||
return !!providerSettings?.clientId;
|
||||
};
|
||||
|
||||
const getProviderSettings = (provider: Provider): ProviderSettings => {
|
||||
if (provider.id === "saml2") {
|
||||
return settings?.saml2 || {};
|
||||
}
|
||||
|
||||
if (provider.id === "smtp") {
|
||||
return settings?.mail || {};
|
||||
}
|
||||
|
||||
if (provider.id === "telegram") {
|
||||
return settings?.telegram || {};
|
||||
}
|
||||
|
||||
if (provider.id === "googledrive") {
|
||||
const gd: GoogleDriveSettings = {
|
||||
enabled: settings?.googleDriveEnabled,
|
||||
clientId: settings?.googleDriveClientId,
|
||||
apiKey: settings?.googleDriveApiKey,
|
||||
appId: settings?.googleDriveAppId,
|
||||
};
|
||||
return gd;
|
||||
}
|
||||
|
||||
if (provider.id === "oauth2-generic") {
|
||||
const generic: OAuth2GenericSettings = {
|
||||
enabled: settings?.oauth2?.enabled,
|
||||
provider: settings?.oauth2?.provider,
|
||||
issuer: settings?.oauth2?.issuer,
|
||||
clientId: settings?.oauth2?.clientId,
|
||||
clientSecret: settings?.oauth2?.clientSecret,
|
||||
scopes: settings?.oauth2?.scopes,
|
||||
useAsUsername: settings?.oauth2?.useAsUsername,
|
||||
autoCreateUser: settings?.oauth2?.autoCreateUser,
|
||||
blockRegistration: settings?.oauth2?.blockRegistration,
|
||||
};
|
||||
return generic;
|
||||
}
|
||||
|
||||
// Specific OAuth2 provider settings
|
||||
return settings?.oauth2?.client?.[provider.id] || {};
|
||||
};
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const linkedProviders = allProviders.filter((p) => isProviderConfigured(p));
|
||||
const availableProviders = allProviders.filter(
|
||||
(p) => !isProviderConfigured(p),
|
||||
);
|
||||
|
||||
const updateProviderSettings = (
|
||||
provider: Provider,
|
||||
updatedSettings: Record<string, unknown>,
|
||||
) => {
|
||||
if (provider.id === "smtp") {
|
||||
setSettings({ ...settings, mail: updatedSettings as MailSettings });
|
||||
} else if (provider.id === "telegram") {
|
||||
setSettings({
|
||||
...settings,
|
||||
telegram: updatedSettings as TelegramSettingsData,
|
||||
});
|
||||
} else if (provider.id === "googledrive") {
|
||||
const gd = updatedSettings as GoogleDriveSettings;
|
||||
setSettings({
|
||||
...settings,
|
||||
googleDriveEnabled: gd.enabled,
|
||||
googleDriveClientId: gd.clientId,
|
||||
googleDriveApiKey: gd.apiKey,
|
||||
googleDriveAppId: gd.appId,
|
||||
});
|
||||
} else if (provider.id === "saml2") {
|
||||
setSettings({ ...settings, saml2: updatedSettings as Saml2Settings });
|
||||
} else if (provider.id === "oauth2-generic") {
|
||||
const generic = updatedSettings as OAuth2GenericSettings;
|
||||
setSettings({ ...settings, oauth2: { ...settings.oauth2, ...generic } });
|
||||
} else {
|
||||
// Specific OAuth2 provider
|
||||
const clientSettings = updatedSettings as OAuth2ClientSettings;
|
||||
setSettings({
|
||||
...settings,
|
||||
oauth2: {
|
||||
...settings.oauth2,
|
||||
client: {
|
||||
...settings.oauth2?.client,
|
||||
[provider.id]: clientSettings,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="xl" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.connections.title", "Connections")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.connections.description",
|
||||
"Configure external authentication providers like OAuth2 and SAML.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* SSO Auto Login - Premium Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.ssoAutoLogin.label",
|
||||
"SSO Auto Login",
|
||||
)}
|
||||
</Text>
|
||||
<Badge
|
||||
color="grape"
|
||||
size="sm"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate("/settings/adminPlan")}
|
||||
title={t(
|
||||
"admin.settings.badge.clickToUpgrade",
|
||||
"Click to view plan details",
|
||||
)}
|
||||
>
|
||||
PRO
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.ssoAutoLogin.enable",
|
||||
"Enable SSO Auto Login",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.connections.ssoAutoLogin.description",
|
||||
"Automatically redirect to SSO login when authentication is required",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.ssoAutoLogin || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return; // Block change when login disabled
|
||||
setSettings({
|
||||
...settings,
|
||||
ssoAutoLogin: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("ssoAutoLogin")} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Mobile Scanner (QR Code) Upload */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" align="center">
|
||||
<LocalIcon
|
||||
icon="qr-code-rounded"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.label",
|
||||
"Mobile Phone Upload",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Documentation Link */}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner"
|
||||
target="_blank"
|
||||
size="xs"
|
||||
c="blue"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.connections.documentation",
|
||||
"View documentation",
|
||||
)}{" "}
|
||||
↗
|
||||
</Anchor>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.enable",
|
||||
"Enable QR Code Upload",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.description",
|
||||
"Allow users to upload files from mobile devices by scanning a QR code",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="orange" mt={8} fw={500}>
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.note",
|
||||
"Note: Requires Frontend URL to be configured. ",
|
||||
)}
|
||||
<Anchor
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.connections.mobileScanner.link",
|
||||
"Configure in System Settings",
|
||||
)}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.enableMobileScanner || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return; // Block change when login disabled
|
||||
setSettings({
|
||||
...settings,
|
||||
enableMobileScanner: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enableMobileScanner")} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Mobile Scanner Settings - Only show when enabled */}
|
||||
<Collapse in={settings?.enableMobileScanner || false}>
|
||||
<Stack
|
||||
gap="md"
|
||||
mt="md"
|
||||
ml="lg"
|
||||
style={{
|
||||
borderLeft: "2px solid var(--mantine-color-gray-3)",
|
||||
paddingLeft: "1rem",
|
||||
}}
|
||||
>
|
||||
{/* Convert to PDF */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerConvertToPdf",
|
||||
"Convert Images to PDF",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerConvertToPdfDesc",
|
||||
"Automatically convert uploaded images to PDF format. If disabled, images will be kept as-is.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mobileScannerConvertToPdf !== false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
mobileScannerConvertToPdf: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("mobileScannerConvertToPdf")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* PDF Conversion Settings - Only show when convertToPdf is enabled */}
|
||||
{settings?.mobileScannerConvertToPdf !== false && (
|
||||
<>
|
||||
{/* Image Resolution */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerImageResolution",
|
||||
"Image Resolution",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerImageResolutionDesc",
|
||||
'Resolution of uploaded images. "Reduced" scales images to max 1200px to reduce file size.',
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
value={
|
||||
settings?.mobileScannerImageResolution || "full"
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
mobileScannerImageResolution: value || "full",
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: "full",
|
||||
label: t(
|
||||
"admin.settings.connections.imageResolutionFull",
|
||||
"Full (Original Size)",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "reduced",
|
||||
label: t(
|
||||
"admin.settings.connections.imageResolutionReduced",
|
||||
"Reduced (Max 1200px)",
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={!loginEnabled}
|
||||
style={{ width: "250px" }}
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("mobileScannerImageResolution")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Page Format */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerPageFormat",
|
||||
"Page Format",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerPageFormatDesc",
|
||||
'PDF page size for converted images. "Keep" uses original image dimensions.',
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Select
|
||||
value={settings?.mobileScannerPageFormat || "A4"}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
mobileScannerPageFormat: value || "A4",
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: "keep",
|
||||
label: t(
|
||||
"admin.settings.connections.pageFormatKeep",
|
||||
"Keep (Original Dimensions)",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "A4",
|
||||
label: t(
|
||||
"admin.settings.connections.pageFormatA4",
|
||||
"A4 (210×297mm)",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "letter",
|
||||
label: t(
|
||||
"admin.settings.connections.pageFormatLetter",
|
||||
"Letter (8.5×11in)",
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={!loginEnabled}
|
||||
style={{ width: "250px" }}
|
||||
comboboxProps={{ zIndex: Z_INDEX_CONFIG_MODAL }}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("mobileScannerPageFormat")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Stretch to Fit */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerStretchToFit",
|
||||
"Stretch to Fit",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{t(
|
||||
"admin.settings.connections.mobileScannerStretchToFitDesc",
|
||||
"Stretch images to fill the entire page. If disabled, images are centered with preserved aspect ratio.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.mobileScannerStretchToFit || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
mobileScannerStretchToFit: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("mobileScannerStretchToFit")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Linked Services Section - Only show if there are linked providers */}
|
||||
{linkedProviders.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="md">
|
||||
{t(
|
||||
"admin.settings.connections.linkedServices",
|
||||
"Linked Services",
|
||||
)}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{linkedProviders.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isConfigured={true}
|
||||
settings={getProviderSettings(provider)}
|
||||
onChange={(updatedSettings) =>
|
||||
updateProviderSettings(provider, updatedSettings)
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Divider between sections */}
|
||||
{availableProviders.length > 0 && <Divider />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unlinked Services Section */}
|
||||
{availableProviders.length > 0 && (
|
||||
<div>
|
||||
<Text fw={600} size="md" mb="md">
|
||||
{t(
|
||||
"admin.settings.connections.unlinkedServices",
|
||||
"Unlinked Services",
|
||||
)}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
{availableProviders.map((provider) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isConfigured={false}
|
||||
settings={getProviderSettings(provider)}
|
||||
onChange={(updatedSettings) =>
|
||||
updateProviderSettings(provider, updatedSettings)
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={adminSettings.saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1125
File diff suppressed because it is too large
Load Diff
+435
@@ -0,0 +1,435 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
interface UISettingsData {
|
||||
defaultHideUnavailableTools?: boolean;
|
||||
defaultHideUnavailableConversions?: boolean;
|
||||
}
|
||||
|
||||
interface EndpointsSettingsData {
|
||||
toRemove?: string[];
|
||||
groupsToRemove?: string[];
|
||||
}
|
||||
|
||||
export default function AdminEndpointsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<EndpointsSettingsData>({
|
||||
sectionName: "endpoints",
|
||||
});
|
||||
|
||||
const {
|
||||
settings: uiSettings,
|
||||
setSettings: setUiSettings,
|
||||
loading: uiLoading,
|
||||
saving: uiSaving,
|
||||
fetchSettings: fetchUiSettings,
|
||||
saveSettings: saveUiSettings,
|
||||
isFieldPending: isUiFieldPending,
|
||||
} = useAdminSettings<UISettingsData>({
|
||||
sectionName: "ui",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
fetchUiSettings();
|
||||
}
|
||||
}, [loginEnabled, fetchSettings, fetchUiSettings]);
|
||||
|
||||
const {
|
||||
isDirty: isEndpointsDirty,
|
||||
resetToSnapshot: resetEndpointsSnapshot,
|
||||
markSaved: markEndpointsSaved,
|
||||
} = useSettingsDirty(settings, loading);
|
||||
const {
|
||||
isDirty: isUiDirty,
|
||||
resetToSnapshot: resetUiSnapshot,
|
||||
markSaved: markUiSaved,
|
||||
} = useSettingsDirty(uiSettings, uiLoading);
|
||||
|
||||
const isDirty = isEndpointsDirty || isUiDirty;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEndpointsDirty) {
|
||||
markEndpointsSaved();
|
||||
await saveSettings();
|
||||
}
|
||||
if (isUiDirty) {
|
||||
markUiSaved();
|
||||
await saveUiSettings();
|
||||
}
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
if (isEndpointsDirty) {
|
||||
const original = resetEndpointsSnapshot();
|
||||
setSettings(original);
|
||||
}
|
||||
if (isUiDirty) {
|
||||
const original = resetUiSnapshot();
|
||||
setUiSettings(original);
|
||||
}
|
||||
}, [
|
||||
isEndpointsDirty,
|
||||
isUiDirty,
|
||||
resetEndpointsSnapshot,
|
||||
resetUiSnapshot,
|
||||
setSettings,
|
||||
setUiSettings,
|
||||
]);
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading || uiLoading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Complete list of all endpoints from frontend tool registry (alphabetical)
|
||||
const commonEndpoints = [
|
||||
"add-attachments",
|
||||
"add-image",
|
||||
"add-page-numbers",
|
||||
"add-password",
|
||||
"add-stamp",
|
||||
"add-watermark",
|
||||
"adjust-contrast",
|
||||
"auto-redact",
|
||||
"auto-rename",
|
||||
"auto-split-pdf",
|
||||
"automate",
|
||||
"booklet-imposition",
|
||||
"cert-sign",
|
||||
"compare",
|
||||
"compress-pdf",
|
||||
"crop",
|
||||
"dev-airgapped-docs",
|
||||
"dev-api-docs",
|
||||
"dev-folder-scanning-docs",
|
||||
"dev-sso-guide-docs",
|
||||
"edit-table-of-contents",
|
||||
"eml-to-pdf",
|
||||
"extract-image-scans",
|
||||
"extract-images",
|
||||
"file-to-pdf",
|
||||
"flatten",
|
||||
"get-info-on-pdf",
|
||||
"handleData",
|
||||
"html-to-pdf",
|
||||
"img-to-pdf",
|
||||
"markdown-to-pdf",
|
||||
"merge-pdfs",
|
||||
"multi-page-layout",
|
||||
"multi-tool",
|
||||
"ocr-pdf",
|
||||
"overlay-pdf",
|
||||
"pdf-to-csv",
|
||||
"pdf-to-xlsx",
|
||||
"pdf-to-epub",
|
||||
"pdf-to-html",
|
||||
"pdf-to-img",
|
||||
"pdf-to-markdown",
|
||||
"pdf-to-pdfa",
|
||||
"pdf-to-presentation",
|
||||
"pdf-to-single-page",
|
||||
"pdf-to-text",
|
||||
"pdf-to-word",
|
||||
"pdf-to-xml",
|
||||
"pipeline",
|
||||
"rearrange-pages",
|
||||
"remove-annotations",
|
||||
"remove-blanks",
|
||||
"remove-cert-sign",
|
||||
"remove-image-pdf",
|
||||
"remove-pages",
|
||||
"remove-password",
|
||||
"repair",
|
||||
"replace-invert-pdf",
|
||||
"rotate-pdf",
|
||||
"sanitize-pdf",
|
||||
"scale-pages",
|
||||
"scanner-effect",
|
||||
"show-javascript",
|
||||
"sign",
|
||||
"split-by-size-or-count",
|
||||
"split-pages",
|
||||
"split-pdf-by-chapters",
|
||||
"split-pdf-by-sections",
|
||||
"text-editor-pdf",
|
||||
"unlock-pdf-forms",
|
||||
"update-metadata",
|
||||
"validate-signature",
|
||||
"view-pdf",
|
||||
];
|
||||
|
||||
// Complete list of functional and tool groups from EndpointConfiguration.java
|
||||
const commonGroups = [
|
||||
// Functional Groups
|
||||
"PageOps",
|
||||
"Convert",
|
||||
"Security",
|
||||
"Other",
|
||||
"Advance",
|
||||
"Automation",
|
||||
"DeveloperTools",
|
||||
"DeveloperDocs",
|
||||
// Tool Groups
|
||||
"CLI",
|
||||
"Python",
|
||||
"OpenCV",
|
||||
"LibreOffice",
|
||||
"Unoconvert",
|
||||
"Java",
|
||||
"Javascript",
|
||||
"qpdf",
|
||||
"Ghostscript",
|
||||
"ImageMagick",
|
||||
"tesseract",
|
||||
"OCRmyPDF",
|
||||
"Weasyprint",
|
||||
"Pdftohtml",
|
||||
"Calibre",
|
||||
"FFmpeg",
|
||||
"veraPDF",
|
||||
"rar",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.endpoints.title", "API Endpoints")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.endpoints.description",
|
||||
"Control which API endpoints and endpoint groups are available.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("admin.settings.endpoints.management", "Endpoint Management")}
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.endpoints.toRemove.label",
|
||||
"Disabled Endpoints",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("toRemove")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.endpoints.toRemove.description",
|
||||
"Select individual endpoints to disable",
|
||||
)}
|
||||
value={settings.toRemove || []}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, toRemove: value });
|
||||
}}
|
||||
data={commonEndpoints.map((endpoint) => ({
|
||||
value: endpoint,
|
||||
label: endpoint,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select endpoints to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MultiSelect
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.endpoints.groupsToRemove.label",
|
||||
"Disabled Endpoint Groups",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("groupsToRemove")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.endpoints.groupsToRemove.description",
|
||||
"Select endpoint groups to disable",
|
||||
)}
|
||||
value={settings.groupsToRemove || []}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, groupsToRemove: value });
|
||||
}}
|
||||
data={commonGroups.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="Select groups to disable"
|
||||
comboboxProps={{ zIndex: 1400 }}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t(
|
||||
"admin.settings.endpoints.userDefaults",
|
||||
"User Preference Defaults",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.endpoints.userDefaultsDescription",
|
||||
"Set default values for user preferences. Users can override these in their personal settings.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.endpoints.defaultHideUnavailableTools.label",
|
||||
"Hide unavailable tools by default",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isUiFieldPending("defaultHideUnavailableTools")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.endpoints.defaultHideUnavailableTools.description",
|
||||
"Remove disabled tools instead of showing them greyed out",
|
||||
)}
|
||||
checked={uiSettings.defaultHideUnavailableTools || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setUiSettings({
|
||||
...uiSettings,
|
||||
defaultHideUnavailableTools: e.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.endpoints.defaultHideUnavailableConversions.label",
|
||||
"Hide unavailable conversions by default",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isUiFieldPending("defaultHideUnavailableConversions")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.endpoints.defaultHideUnavailableConversions.description",
|
||||
"Remove disabled conversion options instead of showing them greyed out",
|
||||
)}
|
||||
checked={uiSettings.defaultHideUnavailableConversions || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setUiSettings({
|
||||
...uiSettings,
|
||||
defaultHideUnavailableConversions: e.currentTarget.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving || uiSaving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Switch,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
interface FeaturesSettingsData {
|
||||
serverCertificate?: {
|
||||
enabled?: boolean;
|
||||
organizationName?: string;
|
||||
validity?: number;
|
||||
regenerateOnStartup?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminFeaturesSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
|
||||
useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<FeaturesSettingsData>({
|
||||
sectionName: "features",
|
||||
fetchTransformer: async (): Promise<
|
||||
FeaturesSettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
const systemResponse = await apiClient.get(
|
||||
"/api/v1/admin/settings/section/system",
|
||||
);
|
||||
const systemData = systemResponse.data || {};
|
||||
|
||||
const result: FeaturesSettingsData & {
|
||||
_pending?: Record<string, unknown>;
|
||||
} = {
|
||||
serverCertificate: systemData.serverCertificate || {
|
||||
enabled: true,
|
||||
organizationName: "Stirling-PDF",
|
||||
validity: 365,
|
||||
regenerateOnStartup: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Map pending changes from system._pending.serverCertificate
|
||||
if (systemData._pending?.serverCertificate) {
|
||||
result._pending = {
|
||||
serverCertificate: systemData._pending.serverCertificate,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings: FeaturesSettingsData) => {
|
||||
const deltaSettings: Record<string, unknown> = {};
|
||||
|
||||
if (settings.serverCertificate) {
|
||||
deltaSettings["system.serverCertificate.enabled"] =
|
||||
settings.serverCertificate.enabled;
|
||||
deltaSettings["system.serverCertificate.organizationName"] =
|
||||
settings.serverCertificate.organizationName;
|
||||
deltaSettings["system.serverCertificate.validity"] =
|
||||
settings.serverCertificate.validity;
|
||||
deltaSettings["system.serverCertificate.regenerateOnStartup"] =
|
||||
settings.serverCertificate.regenerateOnStartup;
|
||||
}
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.features.title", "Features")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.features.description",
|
||||
"Configure optional features and functionality.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Server Certificate - Pro Feature */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.label",
|
||||
"Server Certificate",
|
||||
)}
|
||||
</Text>
|
||||
<Badge
|
||||
color="grape"
|
||||
size="sm"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate("/settings/adminPlan")}
|
||||
title={t(
|
||||
"admin.settings.badge.clickToUpgrade",
|
||||
"Click to view plan details",
|
||||
)}
|
||||
>
|
||||
PRO
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.description",
|
||||
'Configure server-side certificate generation for "Sign with Stirling-PDF" functionality',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.enabled.label",
|
||||
"Enable Server Certificate",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.enabled.description",
|
||||
'Enable server-side certificate for "Sign with Stirling-PDF" option',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.serverCertificate?.enabled ?? true}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
serverCertificate: {
|
||||
...settings.serverCertificate,
|
||||
enabled: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("serverCertificate.enabled")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.organizationName.label",
|
||||
"Organization Name",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending(
|
||||
"serverCertificate.organizationName",
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.features.serverCertificate.organizationName.description",
|
||||
"Organization name for generated certificates",
|
||||
)}
|
||||
value={
|
||||
settings.serverCertificate?.organizationName || "Stirling-PDF"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
serverCertificate: {
|
||||
...settings.serverCertificate,
|
||||
organizationName: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="Stirling-PDF"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.validity.label",
|
||||
"Certificate Validity (days)",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending("serverCertificate.validity")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.features.serverCertificate.validity.description",
|
||||
"Number of days the certificate will be valid",
|
||||
)}
|
||||
value={settings.serverCertificate?.validity ?? 365}
|
||||
onChange={(value) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
serverCertificate: {
|
||||
...settings.serverCertificate,
|
||||
validity: Number(value),
|
||||
},
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={3650}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.regenerateOnStartup.label",
|
||||
"Regenerate on Startup",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.features.serverCertificate.regenerateOnStartup.description",
|
||||
"Generate new certificate on each application startup",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={
|
||||
settings.serverCertificate?.regenerateOnStartup ?? false
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
serverCertificate: {
|
||||
...settings.serverCertificate,
|
||||
regenerateOnStartup: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("serverCertificate.regenerateOnStartup")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1336
File diff suppressed because it is too large
Load Diff
+285
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TextInput,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
interface LegalSettingsData {
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
|
||||
export default function AdminLegalSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<LegalSettingsData>({
|
||||
sectionName: "legal",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.legal.title", "Legal Documents")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.legal.description",
|
||||
"Configure links to legal documents and policies.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Legal Disclaimer */}
|
||||
<Alert
|
||||
icon={<WarningIcon style={{ fontSize: 18 }} />}
|
||||
title={t(
|
||||
"admin.settings.legal.disclaimer.title",
|
||||
"Legal Responsibility Warning",
|
||||
)}
|
||||
color="yellow"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"admin.settings.legal.disclaimer.message",
|
||||
"By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.legal.termsAndConditions.label",
|
||||
"Terms and Conditions",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("termsAndConditions")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.legal.termsAndConditions.description",
|
||||
"URL or filename to terms and conditions",
|
||||
)}
|
||||
value={settings.termsAndConditions || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
termsAndConditions: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://example.com/terms"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.legal.privacyPolicy.label",
|
||||
"Privacy Policy",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("privacyPolicy")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.legal.privacyPolicy.description",
|
||||
"URL or filename to privacy policy",
|
||||
)}
|
||||
value={settings.privacyPolicy || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, privacyPolicy: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com/privacy"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.legal.accessibilityStatement.label",
|
||||
"Accessibility Statement",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending("accessibilityStatement")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.legal.accessibilityStatement.description",
|
||||
"URL or filename to accessibility statement",
|
||||
)}
|
||||
value={settings.accessibilityStatement || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
accessibilityStatement: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://example.com/accessibility"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.legal.cookiePolicy.label",
|
||||
"Cookie Policy",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("cookiePolicy")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.legal.cookiePolicy.description",
|
||||
"URL or filename to cookie policy",
|
||||
)}
|
||||
value={settings.cookiePolicy || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, cookiePolicy: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com/cookies"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.legal.impressum.label", "Impressum")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("impressum")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.legal.impressum.description",
|
||||
"URL or filename to impressum (required in some jurisdictions)",
|
||||
)}
|
||||
value={settings.impressum || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, impressum: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com/impressum"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Switch,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import EditableSecretField from "@app/components/shared/EditableSecretField";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
|
||||
interface MailSettingsData {
|
||||
enabled?: boolean;
|
||||
enableInvites?: boolean;
|
||||
inviteLinkExpiryHours?: number;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type MailApiResponse = MailSettingsData &
|
||||
ApiResponseWithPending<MailSettingsData>;
|
||||
|
||||
export default function AdminMailSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<MailSettingsData>({
|
||||
sectionName: "mail",
|
||||
fetchTransformer: async (): Promise<
|
||||
MailSettingsData & { _pending?: Partial<MailSettingsData> }
|
||||
> => {
|
||||
const mailResponse = await apiClient.get<MailApiResponse>(
|
||||
"/api/v1/admin/settings/section/mail",
|
||||
);
|
||||
return mailResponse.data || {};
|
||||
},
|
||||
saveTransformer: (settings: MailSettingsData) => {
|
||||
return {
|
||||
sectionData: settings,
|
||||
deltaSettings: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.mail.title", "Mail Configuration")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.mail.description",
|
||||
"Configure SMTP settings for email notifications.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("admin.settings.mail.enabled.label", "Enable Mail")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mail.enabled.description",
|
||||
"Enable email notifications and SMTP functionality",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mail.enableInvites.label",
|
||||
"Enable Email Invites",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mail.enableInvites.description",
|
||||
"Allow admins to invite users via email with auto-generated passwords",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="orange" mt={8} fw={500}>
|
||||
{t(
|
||||
"admin.settings.mail.frontendUrlNote.note",
|
||||
"Note: Requires Frontend URL to be configured. ",
|
||||
)}
|
||||
<Anchor
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.mail.frontendUrlNote.link",
|
||||
"Configure in System Settings",
|
||||
)}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enableInvites || false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
enableInvites: e.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enableInvites")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mail.host.label", "SMTP Host")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("host")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mail.host.description",
|
||||
"SMTP server hostname",
|
||||
)}
|
||||
value={settings.host || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, host: e.target.value })
|
||||
}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NumberInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mail.port.label", "SMTP Port")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("port")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mail.port.description",
|
||||
"SMTP server port (typically 587 for TLS, 465 for SSL)",
|
||||
)}
|
||||
value={settings.port || 587}
|
||||
onChange={(value) =>
|
||||
setSettings({ ...settings, port: Number(value) })
|
||||
}
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mail.username.label", "SMTP Username")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("username")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mail.username.description",
|
||||
"SMTP authentication username",
|
||||
)}
|
||||
value={settings.username || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, username: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Group gap="xs" align="center" mb={4}>
|
||||
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
|
||||
{t("admin.settings.mail.password.label", "SMTP Password")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("password")} />
|
||||
</Group>
|
||||
<EditableSecretField
|
||||
description={t(
|
||||
"admin.settings.mail.password.description",
|
||||
"SMTP authentication password",
|
||||
)}
|
||||
value={settings.password || ""}
|
||||
onChange={(value) =>
|
||||
setSettings({ ...settings, password: value })
|
||||
}
|
||||
placeholder="Enter SMTP password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mail.from.label", "From Address")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("from")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mail.from.description",
|
||||
"Email address to use as sender",
|
||||
)}
|
||||
value={settings.from || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, from: e.target.value })
|
||||
}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Divider, Loader, Alert } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePlans } from "@app/hooks/usePlans";
|
||||
import licenseService, {
|
||||
PlanTierGroup,
|
||||
mapLicenseToTier,
|
||||
} from "@app/services/licenseService";
|
||||
import { useCheckout } from "@app/contexts/CheckoutContext";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
|
||||
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
|
||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { InfoBanner } from "@app/components/shared/InfoBanner";
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import {
|
||||
getPreferredCurrency,
|
||||
setCachedCurrency,
|
||||
} from "@app/utils/currencyDetection";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@core/components/shared/config/LoginRequiredBanner";
|
||||
import { isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||
|
||||
const AdminPlanSection: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo } = useLicense();
|
||||
const [currency, setCurrency] = useState<string>(() => {
|
||||
// Initialize with auto-detected currency on first render
|
||||
return getPreferredCurrency(i18n.language);
|
||||
});
|
||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||
const { plans, loading, error, refetch } = usePlans(currency);
|
||||
const licenseAlert = useLicenseAlert();
|
||||
|
||||
// Check if we should use static version
|
||||
useEffect(() => {
|
||||
// Only use static version if Supabase is not configured or there's an error
|
||||
// Stripe key is not required - hosted checkout works without it
|
||||
if (!isSupabaseConfigured || error) {
|
||||
setUseStaticVersion(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: "gbp", label: "British pound (GBP, £)" },
|
||||
{ value: "usd", label: "US dollar (USD, $)" },
|
||||
{ value: "eur", label: "Euro (EUR, €)" },
|
||||
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
|
||||
{ value: "inr", label: "Indian rupee (INR, ₹)" },
|
||||
{ value: "brl", label: "Brazilian real (BRL, R$)" },
|
||||
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
|
||||
];
|
||||
|
||||
const handleManageClick = useCallback(async () => {
|
||||
// Block access if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only allow PRO or ENTERPRISE licenses to access billing portal
|
||||
if (!licenseInfo?.licenseType || licenseInfo.licenseType === "NORMAL") {
|
||||
throw new Error(
|
||||
"No valid license found. Please purchase a license before accessing the billing portal.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!licenseInfo?.licenseKey) {
|
||||
throw new Error("License key missing. Please contact support.");
|
||||
}
|
||||
|
||||
// Create billing portal session with license key
|
||||
const response = await licenseService.createBillingPortalSession(
|
||||
window.location.href,
|
||||
licenseInfo.licenseKey,
|
||||
);
|
||||
|
||||
// Open billing portal in new tab
|
||||
window.open(response.url, "_blank");
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to open billing portal:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("billing.portal.error", "Failed to open billing portal"),
|
||||
body:
|
||||
(error instanceof Error ? error.message : undefined) ||
|
||||
"Please try again or contact support.",
|
||||
});
|
||||
}
|
||||
}, [licenseInfo, t, validateLoginEnabled]);
|
||||
|
||||
const handleCurrencyChange = useCallback((newCurrency: string) => {
|
||||
setCurrency(newCurrency);
|
||||
// Persist user's manual selection to localStorage
|
||||
setCachedCurrency(newCurrency);
|
||||
}, []);
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(planGroup: PlanTierGroup) => {
|
||||
// Block access if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow upgrades for server and enterprise tiers
|
||||
if (planGroup.tier === "free") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent free tier users from directly accessing enterprise (must have server first)
|
||||
const currentTier = mapLicenseToTier(licenseInfo);
|
||||
if (currentTier === "free" && planGroup.tier === "enterprise") {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
|
||||
body: t(
|
||||
"plan.enterprise.requiresServerMessage",
|
||||
"Please upgrade to the Server plan first before upgrading to Enterprise.",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use checkout context to open checkout modal
|
||||
openCheckout(planGroup.tier, {
|
||||
currency,
|
||||
onSuccess: () => {
|
||||
// Refetch plans after successful payment
|
||||
// License context will auto-update
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
},
|
||||
[openCheckout, currency, refetch, licenseInfo, t, validateLoginEnabled],
|
||||
);
|
||||
|
||||
const shouldShowLicenseWarning =
|
||||
licenseAlert.active && licenseAlert.audience === "admin";
|
||||
const formattedUserCount = useMemo(() => {
|
||||
if (licenseAlert.totalUsers == null) {
|
||||
return t("plan.licenseWarning.overLimit", "more than {{limit}}", {
|
||||
limit: licenseAlert.freeTierLimit,
|
||||
});
|
||||
}
|
||||
return licenseAlert.totalUsers.toLocaleString();
|
||||
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
|
||||
|
||||
const scrollToPlans = useCallback(() => {
|
||||
const el = document.getElementById("available-plans-section");
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show static version if Stripe is not configured or there's an error
|
||||
if (useStaticVersion) {
|
||||
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
|
||||
}
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "2rem 0",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Fallback to static version on error
|
||||
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
|
||||
}
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
{shouldShowLicenseWarning && (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t(
|
||||
"plan.licenseWarning.title",
|
||||
"Free self-hosted limit reached",
|
||||
)}
|
||||
message={t("plan.licenseWarning.body", {
|
||||
total: formattedUserCount,
|
||||
limit: licenseAlert.freeTierLimit,
|
||||
})}
|
||||
buttonText={t("plan.licenseWarning.cta", "See plans")}
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={scrollToPlans}
|
||||
dismissible={false}
|
||||
minHeight={68}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentLicenseInfo={licenseInfo}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
onManageClick={handleManageClick}
|
||||
currency={currency}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
currencyOptions={currencyOptions}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* License Key Section */}
|
||||
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlanSection;
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TextInput,
|
||||
Switch,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
List,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
interface PremiumSettingsData {
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminPremiumSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
|
||||
useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PremiumSettingsData>({
|
||||
sectionName: "premium",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.premium.title", "Premium & Enterprise")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.premium.description",
|
||||
"Configure your premium or enterprise license key.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Notice about moved features */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t(
|
||||
"admin.settings.premium.movedFeatures.title",
|
||||
"Premium Features Distributed",
|
||||
)}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"admin.settings.premium.movedFeatures.message",
|
||||
"Premium and Enterprise features are now organized in their respective sections:",
|
||||
)}
|
||||
</Text>
|
||||
<List mt="xs" size="sm">
|
||||
<List.Item>
|
||||
<Text size="sm" component="span">
|
||||
<strong>SSO Auto Login</strong> (PRO) - Connections
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text size="sm" component="span">
|
||||
<strong>Custom Metadata</strong> (PRO) - General
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text size="sm" component="span">
|
||||
<strong>Audit Logging</strong> (ENTERPRISE) - Security
|
||||
</Text>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<Text size="sm" component="span">
|
||||
<strong>Database Configuration</strong> (ENTERPRISE) - Database
|
||||
</Text>
|
||||
</List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
{/* License Configuration */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("admin.settings.premium.license", "License Configuration")}
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.premium.key.label", "License Key")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("key")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.premium.key.description",
|
||||
"Enter your premium or enterprise license key",
|
||||
)}
|
||||
value={settings.key || ""}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, key: e.target.value })
|
||||
}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.premium.enabled.label",
|
||||
"Enable Premium Features",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.premium.enabled.description",
|
||||
"Enable license key checks for pro/enterprise features",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({ ...settings, enabled: e.target.checked });
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enabled")} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Stack, Paper, Text, Loader, Group } from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface PrivacySettingsData {
|
||||
enableAnalytics?: boolean;
|
||||
googleVisibility?: boolean;
|
||||
metricsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdminPrivacySection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
|
||||
useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PrivacySettingsData>({
|
||||
sectionName: "privacy",
|
||||
fetchTransformer: async (): Promise<
|
||||
PrivacySettingsData & { _pending?: Record<string, unknown> }
|
||||
> => {
|
||||
const [metricsResponse, systemResponse] = await Promise.all([
|
||||
apiClient.get("/api/v1/admin/settings/section/metrics"),
|
||||
apiClient.get("/api/v1/admin/settings/section/system"),
|
||||
]);
|
||||
|
||||
const metrics = metricsResponse.data;
|
||||
const system = systemResponse.data;
|
||||
|
||||
const result: PrivacySettingsData & {
|
||||
_pending?: Record<string, unknown>;
|
||||
} = {
|
||||
enableAnalytics: system.enableAnalytics || false,
|
||||
googleVisibility: system.googlevisibility || false,
|
||||
metricsEnabled: metrics.enabled || false,
|
||||
};
|
||||
|
||||
// Merge pending blocks from both endpoints
|
||||
const pendingBlock: Record<string, unknown> = {};
|
||||
if (system._pending?.enableAnalytics !== undefined) {
|
||||
pendingBlock.enableAnalytics = system._pending.enableAnalytics;
|
||||
}
|
||||
if (system._pending?.googlevisibility !== undefined) {
|
||||
pendingBlock.googleVisibility = system._pending.googlevisibility;
|
||||
}
|
||||
if (metrics._pending?.enabled !== undefined) {
|
||||
pendingBlock.metricsEnabled = metrics._pending.enabled;
|
||||
}
|
||||
|
||||
if (Object.keys(pendingBlock).length > 0) {
|
||||
result._pending = pendingBlock;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
saveTransformer: (settings: PrivacySettingsData) => {
|
||||
const deltaSettings = {
|
||||
"system.enableAnalytics": settings.enableAnalytics,
|
||||
"system.googlevisibility": settings.googleVisibility,
|
||||
"metrics.enabled": settings.metricsEnabled,
|
||||
};
|
||||
|
||||
return {
|
||||
sectionData: {},
|
||||
deltaSettings,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled, fetchSettings]);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const original = resetToSnapshot();
|
||||
setSettings(original);
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Override loading state when login is disabled
|
||||
const actualLoading = loginEnabled ? loading : false;
|
||||
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.privacy.title", "Privacy")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.privacy.description",
|
||||
"Configure privacy and data collection settings.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Analytics & Tracking */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("admin.settings.privacy.analytics", "Analytics & Tracking")}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.enableAnalytics.label",
|
||||
"Enable Analytics",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.privacy.enableAnalytics.description",
|
||||
"Collect anonymous usage analytics to help improve the application",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.enableAnalytics || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
enableAnalytics: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enableAnalytics")} />
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.metricsEnabled.label",
|
||||
"Enable Metrics",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.privacy.metricsEnabled.description",
|
||||
"Enable collection of performance and usage metrics",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.metricsEnabled || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
metricsEnabled: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("metricsEnabled")} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Search Engine Visibility */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t(
|
||||
"admin.settings.privacy.searchEngine",
|
||||
"Search Engine Visibility",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.privacy.googleVisibility.label",
|
||||
"Google Visibility",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.privacy.googleVisibility.description",
|
||||
"Allow search engines to index this application",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings?.googleVisibility || false}
|
||||
onChange={(e) => {
|
||||
if (!loginEnabled) return;
|
||||
setSettings({
|
||||
...settings,
|
||||
googleVisibility: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("googleVisibility")} />
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1580
File diff suppressed because it is too large
Load Diff
+415
@@ -0,0 +1,415 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { alert } from "@app/components/toast";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
|
||||
interface StorageSharingSettingsData {
|
||||
enabled?: boolean;
|
||||
sharing?: {
|
||||
enabled?: boolean;
|
||||
linkEnabled?: boolean;
|
||||
emailEnabled?: boolean;
|
||||
};
|
||||
signing?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
system?: {
|
||||
frontendUrl?: string;
|
||||
};
|
||||
mail?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminStorageSharingSection() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } =
|
||||
useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<StorageSharingSettingsData>({
|
||||
sectionName: "storage",
|
||||
fetchTransformer: async () => {
|
||||
const [storageResponse, systemResponse, mailResponse] = await Promise.all(
|
||||
[
|
||||
apiClient.get("/api/v1/admin/settings/section/storage"),
|
||||
apiClient.get("/api/v1/admin/settings/section/system"),
|
||||
apiClient.get("/api/v1/admin/settings/section/mail"),
|
||||
],
|
||||
);
|
||||
|
||||
const storageData = storageResponse.data || {};
|
||||
const systemData = systemResponse.data || {};
|
||||
const mailData = mailResponse.data || {};
|
||||
|
||||
return {
|
||||
...storageData,
|
||||
system: { frontendUrl: systemData.frontendUrl || "" },
|
||||
mail: { enabled: mailData.enabled || false },
|
||||
};
|
||||
},
|
||||
saveTransformer: (currentSettings) => ({
|
||||
sectionData: {
|
||||
enabled: currentSettings.enabled,
|
||||
sharing: {
|
||||
enabled: currentSettings.sharing?.enabled,
|
||||
linkEnabled: currentSettings.sharing?.linkEnabled,
|
||||
emailEnabled: currentSettings.sharing?.emailEnabled,
|
||||
},
|
||||
signing: {
|
||||
enabled: currentSettings.signing?.enabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loginEnabled) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [loginEnabled]);
|
||||
|
||||
const storageEnabled = settings.enabled ?? false;
|
||||
const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false);
|
||||
const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim());
|
||||
const mailEnabled = Boolean(settings.mail?.enabled);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setSettings(resetToSnapshot());
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
markSaved();
|
||||
await saveSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled && loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<div className="settings-section-content">
|
||||
<Stack gap="sm">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.storage.title", "File Storage & Sharing")}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t("toolPanel.alpha", "Alpha")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.description",
|
||||
"Control server storage and sharing options.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.storage.enabled.label",
|
||||
"Enable Server File Storage",
|
||||
)}
|
||||
</Text>
|
||||
{isFieldPending("enabled") && <PendingBadge show={true} />}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.enabled.description",
|
||||
"Allow users to store files on the server.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={storageEnabled}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.currentTarget.checked })
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.enabled.label",
|
||||
"Enable Sharing",
|
||||
)}
|
||||
</Text>
|
||||
{isFieldPending("sharing.enabled") && (
|
||||
<PendingBadge show={true} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.enabled.description",
|
||||
"Allow users to share stored files.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.sharing?.enabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
sharing: {
|
||||
...settings.sharing,
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled || !storageEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.links.label",
|
||||
"Enable Share Links",
|
||||
)}
|
||||
</Text>
|
||||
{isFieldPending("sharing.linkEnabled") && (
|
||||
<PendingBadge show={true} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.links.description",
|
||||
"Allow sharing via signed-in links.",
|
||||
)}
|
||||
</Text>
|
||||
{!frontendUrlConfigured && (
|
||||
<Text size="xs" c="orange">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.links.frontendUrlNote",
|
||||
"Requires a Frontend URL. ",
|
||||
)}
|
||||
<Anchor
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminGeneral#frontendUrl");
|
||||
}}
|
||||
c="orange"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.storage.sharing.links.frontendUrlLink",
|
||||
"Configure in System Settings",
|
||||
)}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.sharing?.linkEnabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
sharing: {
|
||||
...settings.sharing,
|
||||
linkEnabled: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
!loginEnabled || !sharingEnabled || !frontendUrlConfigured
|
||||
}
|
||||
styles={getDisabledStyles()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.email.label",
|
||||
"Enable Email Sharing",
|
||||
)}
|
||||
</Text>
|
||||
{isFieldPending("sharing.emailEnabled") && (
|
||||
<PendingBadge show={true} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.email.description",
|
||||
"Allow sharing with email addresses.",
|
||||
)}
|
||||
</Text>
|
||||
{!mailEnabled && (
|
||||
<Text size="xs" c="orange">
|
||||
{t(
|
||||
"admin.settings.storage.sharing.email.mailNote",
|
||||
"Requires mail configuration. ",
|
||||
)}
|
||||
<Anchor
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/settings/adminConnections");
|
||||
}}
|
||||
c="orange"
|
||||
td="underline"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.storage.sharing.email.mailLink",
|
||||
"Configure Mail Settings",
|
||||
)}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.sharing?.emailEnabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
sharing: {
|
||||
...settings.sharing,
|
||||
emailEnabled: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled || !sharingEnabled || !mailEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{t(
|
||||
"admin.settings.storage.signing.enabled.label",
|
||||
"Enable Group Signing",
|
||||
)}
|
||||
</Text>
|
||||
{isFieldPending("signing.enabled") && (
|
||||
<PendingBadge show={true} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.storage.signing.enabled.description",
|
||||
"Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.signing?.enabled ?? false}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
signing: {
|
||||
...settings.signing,
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled || !storageEnabled}
|
||||
styles={getDisabledStyles()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Card,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import usageAnalyticsService, {
|
||||
EndpointStatisticsResponse,
|
||||
} from "@app/services/usageAnalyticsService";
|
||||
import UsageAnalyticsChart from "@app/components/shared/config/configSections/usage/UsageAnalyticsChart";
|
||||
import UsageAnalyticsTable from "@app/components/shared/config/configSections/usage/UsageAnalyticsTable";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import EnterpriseRequiredBanner from "@app/components/shared/config/EnterpriseRequiredBanner";
|
||||
|
||||
const AdminUsageSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const { config } = useAppConfig();
|
||||
const licenseType = config?.license ?? "NORMAL";
|
||||
const hasEnterpriseLicense = licenseType === "ENTERPRISE";
|
||||
const showDemoData = !loginEnabled || !hasEnterpriseLicense;
|
||||
const [data, setData] = useState<EndpointStatisticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<"top10" | "top20" | "all">(
|
||||
"top10",
|
||||
);
|
||||
const [dataType, setDataType] = useState<"all" | "api" | "ui">("api");
|
||||
|
||||
const buildDemoUsageData = useCallback((): EndpointStatisticsResponse => {
|
||||
const totalVisits = 15847;
|
||||
const allEndpoints = [
|
||||
{
|
||||
endpoint: "merge-pdfs",
|
||||
visits: 3245,
|
||||
percentage: (3245 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "compress-pdf",
|
||||
visits: 2891,
|
||||
percentage: (2891 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "pdf-to-img",
|
||||
visits: 2156,
|
||||
percentage: (2156 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "split-pdf",
|
||||
visits: 1834,
|
||||
percentage: (1834 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "rotate-pdf",
|
||||
visits: 1523,
|
||||
percentage: (1523 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "ocr-pdf",
|
||||
visits: 1287,
|
||||
percentage: (1287 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "add-watermark",
|
||||
visits: 945,
|
||||
percentage: (945 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "extract-images",
|
||||
visits: 782,
|
||||
percentage: (782 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "add-password",
|
||||
visits: 621,
|
||||
percentage: (621 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "html-to-pdf",
|
||||
visits: 563,
|
||||
percentage: (563 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "remove-password",
|
||||
visits: 487,
|
||||
percentage: (487 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "pdf-to-pdfa",
|
||||
visits: 423,
|
||||
percentage: (423 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "extract-pdf-metadata",
|
||||
visits: 356,
|
||||
percentage: (356 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "add-page-numbers",
|
||||
visits: 298,
|
||||
percentage: (298 / totalVisits) * 100,
|
||||
},
|
||||
{ endpoint: "crop", visits: 245, percentage: (245 / totalVisits) * 100 },
|
||||
{
|
||||
endpoint: "flatten",
|
||||
visits: 187,
|
||||
percentage: (187 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "sanitize-pdf",
|
||||
visits: 134,
|
||||
percentage: (134 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "auto-split-pdf",
|
||||
visits: 98,
|
||||
percentage: (98 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "scale-pages",
|
||||
visits: 76,
|
||||
percentage: (76 / totalVisits) * 100,
|
||||
},
|
||||
{
|
||||
endpoint: "compare-pdfs",
|
||||
visits: 42,
|
||||
percentage: (42 / totalVisits) * 100,
|
||||
},
|
||||
];
|
||||
|
||||
let filteredEndpoints = allEndpoints;
|
||||
if (displayMode === "top10") {
|
||||
filteredEndpoints = allEndpoints.slice(0, 10);
|
||||
} else if (displayMode === "top20") {
|
||||
filteredEndpoints = allEndpoints.slice(0, 20);
|
||||
}
|
||||
|
||||
return {
|
||||
totalVisits,
|
||||
totalEndpoints: filteredEndpoints.length,
|
||||
endpoints: filteredEndpoints,
|
||||
};
|
||||
}, [displayMode]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const limit =
|
||||
displayMode === "all" ? undefined : displayMode === "top10" ? 10 : 20;
|
||||
const response = await usageAnalyticsService.getEndpointStatistics(
|
||||
limit,
|
||||
dataType,
|
||||
);
|
||||
|
||||
setData(response);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load usage statistics",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dataType, displayMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDemoData) {
|
||||
fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
// Provide example usage analytics data when running in demo mode
|
||||
setError(null);
|
||||
setData(buildDemoUsageData());
|
||||
setLoading(false);
|
||||
}, [buildDemoUsageData, fetchData, showDemoData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (showDemoData) {
|
||||
setData(buildDemoUsageData());
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const getDisplayModeLabel = () => {
|
||||
switch (displayMode) {
|
||||
case "top10":
|
||||
return t("usage.showing.top10", "Top 10");
|
||||
case "top20":
|
||||
return t("usage.showing.top20", "Top 20");
|
||||
case "all":
|
||||
return t("usage.showing.all", "All");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Override loading state when showing demo data
|
||||
const actualLoading = showDemoData ? false : loading;
|
||||
|
||||
// Early returns for loading/error states
|
||||
if (actualLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: "2rem" }}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("usage.error", "Error loading usage statistics")}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title={t("usage.noData", "No data available")}>
|
||||
{t(
|
||||
"usage.noDataMessage",
|
||||
"No usage statistics are currently available.",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const endpoints = (data?.endpoints ?? []).map((endpoint) => ({
|
||||
endpoint:
|
||||
endpoint.endpoint ?? t("usage.table.unknownEndpoint", "Unknown endpoint"),
|
||||
visits: Number.isFinite(endpoint.visits) ? Math.max(0, endpoint.visits) : 0,
|
||||
percentage: Number.isFinite(endpoint.percentage)
|
||||
? Math.max(0, endpoint.percentage)
|
||||
: 0,
|
||||
}));
|
||||
|
||||
const chartData = endpoints.map((e) => ({
|
||||
label: e.endpoint,
|
||||
value: Number.isFinite(e.visits) ? Math.max(0, e.visits) : 0,
|
||||
}));
|
||||
|
||||
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
|
||||
const totalVisits = Number.isFinite(data?.totalVisits)
|
||||
? Math.max(0, data?.totalVisits ?? 0)
|
||||
: displayedVisits;
|
||||
const totalEndpoints = Number.isFinite(data?.totalEndpoints)
|
||||
? Math.max(0, data?.totalEndpoints ?? 0)
|
||||
: endpoints.length;
|
||||
|
||||
const displayedPercentage =
|
||||
totalVisits > 0
|
||||
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<EnterpriseRequiredBanner
|
||||
show={!hasEnterpriseLicense}
|
||||
featureName={t(
|
||||
"settings.licensingAnalytics.usageAnalytics",
|
||||
"Usage Analytics",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Info banner about usage analytics and audit relationship */}
|
||||
{loginEnabled && hasEnterpriseLicense && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />}
|
||||
title={t("usage.aboutUsageAnalytics", "About Usage Analytics")}
|
||||
color="cyan"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"usage.usageAnalyticsExplanation",
|
||||
"Usage analytics track endpoint requests and tool usage patterns. Combined with the Audit Logging dashboard, you get complete visibility into system activity, performance, and security events.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate("/settings/adminSecurity")}
|
||||
rightSection={
|
||||
<LocalIcon
|
||||
icon="arrow-forward"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("usage.configureSettings", "Configure Analytics Settings")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navigate("/settings/adminSecurity#auditLogging")}
|
||||
rightSection={
|
||||
<LocalIcon
|
||||
icon="arrow-forward"
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("usage.viewAuditLogs", "View Audit Logs")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<SegmentedControl
|
||||
value={displayMode}
|
||||
onChange={(value) =>
|
||||
setDisplayMode(value as "top10" | "top20" | "all")
|
||||
}
|
||||
disabled={showDemoData}
|
||||
data={[
|
||||
{
|
||||
value: "top10",
|
||||
label: t("usage.controls.top10", "Top 10"),
|
||||
},
|
||||
{
|
||||
value: "top20",
|
||||
label: t("usage.controls.top20", "Top 20"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("usage.controls.all", "All"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={
|
||||
<LocalIcon icon="refresh" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
disabled={showDemoData}
|
||||
>
|
||||
{t("usage.controls.refresh", "Refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("usage.controls.dataTypeLabel", "Data Type:")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={dataType}
|
||||
onChange={(value) => setDataType(value as "all" | "api" | "ui")}
|
||||
disabled={showDemoData}
|
||||
data={[
|
||||
{
|
||||
value: "all",
|
||||
label: t("usage.controls.dataType.all", "All"),
|
||||
},
|
||||
{
|
||||
value: "api",
|
||||
label: t("usage.controls.dataType.api", "API"),
|
||||
},
|
||||
{
|
||||
value: "ui",
|
||||
label: t("usage.controls.dataType.ui", "UI"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Statistics Summary */}
|
||||
<Group gap="xl" style={{ flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("usage.stats.totalEndpoints", "Total Endpoints")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{totalEndpoints}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("usage.stats.totalVisits", "Total Visits")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{totalVisits.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("usage.stats.showing", "Showing")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{getDisplayModeLabel()}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("usage.stats.selectedVisits", "Selected Visits")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{displayedVisits.toLocaleString()} ({displayedPercentage}%)
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Chart and Table */}
|
||||
<UsageAnalyticsChart data={chartData} />
|
||||
<UsageAnalyticsTable data={endpoints} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsageSection;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import ApiKeySection from "./apiKeys/ApiKeySection";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import RefreshModal from "./apiKeys/RefreshModal";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import useApiKey from "./apiKeys/hooks/useApiKey";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
export default function ApiKeys() {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [showRefreshModal, setShowRefreshModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
isLoading: apiKeyLoading,
|
||||
refresh,
|
||||
isRefreshing,
|
||||
error: apiKeyError,
|
||||
refetch,
|
||||
} = useApiKey();
|
||||
|
||||
const copy = async (text: string, tag: string) => {
|
||||
try {
|
||||
// Try modern Clipboard API first (requires HTTPS)
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(tag);
|
||||
setTimeout(() => setCopied(null), 1600);
|
||||
} else {
|
||||
// Fallback for HTTP: use old execCommand method
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
if (document.execCommand("copy")) {
|
||||
setCopied(tag);
|
||||
setTimeout(() => setCopied(null), 1600);
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to copy:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshKeys = async () => {
|
||||
try {
|
||||
await refresh();
|
||||
} finally {
|
||||
setShowRefreshModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={20} p={0}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"config.apiKeys.intro",
|
||||
"Use your API key to programmatically access Stirling PDF's processing capabilities.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Paper
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
background: "var(--bg-muted)",
|
||||
border: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<LocalIcon
|
||||
icon="info-rounded"
|
||||
width={18}
|
||||
height={18}
|
||||
style={{ marginTop: 2, flexShrink: 0, opacity: 0.7 }}
|
||||
/>
|
||||
<Stack gap={8} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("config.apiKeys.docsTitle", "API Documentation")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"config.apiKeys.docsDescription",
|
||||
"Learn more about integrating with Stirling PDF:",
|
||||
)}
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm">
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/API"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t("config.apiKeys.docsLink", "API Documentation")}
|
||||
<LocalIcon
|
||||
icon="open-in-new-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<Anchor
|
||||
href="https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t("config.apiKeys.schemaLink", "API Schema Reference")}
|
||||
<LocalIcon
|
||||
icon="open-in-new-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{apiKeyError && (
|
||||
<Text size="sm" c="red.5">
|
||||
{t(
|
||||
"config.apiKeys.generateError",
|
||||
"We couldn't generate your API key.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
underline="always"
|
||||
onClick={refetch}
|
||||
c="red.4"
|
||||
>
|
||||
{t("common.retry", "Retry")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{apiKeyLoading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 18,
|
||||
borderRadius: 12,
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Group align="center" gap={12} wrap="nowrap">
|
||||
<Skeleton height={36} style={{ flex: 1 }} />
|
||||
<Skeleton height={32} width={76} />
|
||||
<Skeleton height={32} width={92} />
|
||||
</Group>
|
||||
</div>
|
||||
) : (
|
||||
<ApiKeySection
|
||||
publicKey={apiKey ?? ""}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onRefresh={() => setShowRefreshModal(true)}
|
||||
disabled={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text size="sm" c="dimmed" style={{ marginTop: -8 }}>
|
||||
{t(
|
||||
"config.apiKeys.usage",
|
||||
"Include this key in the X-API-KEY header with all API requests.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<RefreshModal
|
||||
opened={showRefreshModal}
|
||||
onClose={() => setShowRefreshModal(false)}
|
||||
onConfirm={refreshKeys}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+1037
File diff suppressed because it is too large
Load Diff
+838
@@ -0,0 +1,838 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
Menu,
|
||||
Avatar,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { teamService, Team } from "@app/services/teamService";
|
||||
import {
|
||||
User,
|
||||
userManagementService,
|
||||
} from "@app/services/userManagementService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
|
||||
|
||||
interface TeamDetailsSectionProps {
|
||||
teamId: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function TeamDetailsSection({
|
||||
teamId,
|
||||
onBack,
|
||||
}: TeamDetailsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [team, setTeam] = useState<Team | null>(null);
|
||||
const [teamUsers, setTeamUsers] = useState<User[]>([]);
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>([]);
|
||||
const [userLastRequest, setUserLastRequest] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
|
||||
const [changePasswordModalOpened, setChangePasswordModalOpened] =
|
||||
useState(false);
|
||||
const [passwordUser, setPasswordUser] = useState<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
const [mailEnabled, setMailEnabled] = useState(false);
|
||||
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
|
||||
|
||||
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
fetchAllTeams();
|
||||
}, [teamId]);
|
||||
|
||||
const fetchTeamDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [data, adminData] = await Promise.all([
|
||||
teamService.getTeamDetails(teamId),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
console.log("[TeamDetailsSection] Raw data:", data);
|
||||
setTeam(data.team);
|
||||
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
|
||||
setAvailableUsers(
|
||||
Array.isArray(data.availableUsers) ? data.availableUsers : [],
|
||||
);
|
||||
setUserLastRequest(data.userLastRequest || {});
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
setMailEnabled(adminData.mailEnabled);
|
||||
setLockedUsers(adminData.lockedUsers || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch team details:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.loadError", "Failed to load team details"),
|
||||
});
|
||||
onBack();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllTeams = async () => {
|
||||
try {
|
||||
const teams = await teamService.getTeams();
|
||||
setAllTeams(teams);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch teams:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedUserId) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.teams.addMemberToTeam.selectUserRequired",
|
||||
"Please select a user",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.teams.addMemberToTeam.success",
|
||||
"User added to team successfully",
|
||||
),
|
||||
});
|
||||
setAddMemberModalOpened(false);
|
||||
setSelectedUserId("");
|
||||
fetchTeamDetails();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to add member:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t(
|
||||
"workspace.teams.addMemberToTeam.error",
|
||||
"Failed to add user to team",
|
||||
);
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (user: User) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"workspace.teams.confirmRemove",
|
||||
`Remove ${user.username} from this team?`,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
// Find the Default team ID
|
||||
const defaultTeam = allTeams.find((t) => t.name === "Default");
|
||||
|
||||
if (!defaultTeam) {
|
||||
throw new Error("Default team not found");
|
||||
}
|
||||
|
||||
// Move user to Default team by updating their role with the Default team ID
|
||||
await teamService.moveUserToTeam(
|
||||
user.username,
|
||||
user.rolesAsString || "ROLE_USER",
|
||||
defaultTeam.id,
|
||||
);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.teams.removeMemberSuccess",
|
||||
"User removed from team",
|
||||
),
|
||||
});
|
||||
fetchTeamDetails();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to remove member:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t(
|
||||
"workspace.teams.removeMemberError",
|
||||
"Failed to remove user from team",
|
||||
);
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
const confirmMessage = t(
|
||||
"workspace.people.confirmDelete",
|
||||
"Are you sure you want to delete this user? This action cannot be undone.",
|
||||
);
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.deleteUser(user.username);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.deleteUserSuccess",
|
||||
"User deleted successfully",
|
||||
),
|
||||
});
|
||||
fetchTeamDetails();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to delete user:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.people.deleteUserError", "Failed to delete user");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockUser = async (user: User) => {
|
||||
const confirmMessage = t(
|
||||
"workspace.people.confirmUnlock",
|
||||
"Are you sure you want to unlock this user account?",
|
||||
);
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagementService.unlockUser(user.username);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.people.unlockUserSuccess",
|
||||
"User account unlocked successfully",
|
||||
),
|
||||
});
|
||||
fetchTeamDetails();
|
||||
} catch (error: unknown) {
|
||||
console.error("[TeamDetailsSection] Failed to unlock user:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t(
|
||||
"workspace.people.unlockUserError",
|
||||
"Failed to unlock user account",
|
||||
);
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openChangeTeamModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedTeamId(user.team?.id?.toString() || "");
|
||||
setChangeTeamModalOpened(true);
|
||||
};
|
||||
|
||||
const openChangePasswordModal = (user: User) => {
|
||||
setPasswordUser(user);
|
||||
setChangePasswordModalOpened(true);
|
||||
};
|
||||
|
||||
const closeChangePasswordModal = () => {
|
||||
setChangePasswordModalOpened(false);
|
||||
setPasswordUser(null);
|
||||
};
|
||||
|
||||
const handleChangeTeam = async () => {
|
||||
if (!selectedUser || !selectedTeamId) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t(
|
||||
"workspace.teams.changeTeam.selectTeamRequired",
|
||||
"Please select a team",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.moveUserToTeam(
|
||||
selectedUser.username,
|
||||
selectedUser.rolesAsString || "ROLE_USER",
|
||||
parseInt(selectedTeamId),
|
||||
);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"workspace.teams.changeTeam.success",
|
||||
"Team changed successfully",
|
||||
),
|
||||
});
|
||||
setChangeTeamModalOpened(false);
|
||||
setSelectedUser(null);
|
||||
setSelectedTeamId("");
|
||||
fetchTeamDetails();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to change team:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.teams.changeTeam.error", "Failed to change team");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("workspace.teams.loadingDetails", "Loading team details...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<Stack align="center" py="xl">
|
||||
<Text size="sm" c="red">
|
||||
{t("workspace.teams.teamNotFound", "Team not found")}
|
||||
</Text>
|
||||
<Button variant="light" onClick={onBack}>
|
||||
{t("workspace.teams.backToTeams", "Back to Teams")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header with back button */}
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={onBack}>
|
||||
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
|
||||
</ActionIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600} size="lg">
|
||||
{team.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("workspace.teams.memberCount", { count: teamUsers.length })}{" "}
|
||||
{teamUsers.length === 1 ? "member" : "members"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Add Member Button */}
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={t(
|
||||
"workspace.people.license.noSlotsAvailable",
|
||||
"No user slots available",
|
||||
)}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Button
|
||||
leftSection={
|
||||
<LocalIcon icon="person-add" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={
|
||||
team.name === "Internal" ||
|
||||
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
|
||||
}
|
||||
>
|
||||
{t("workspace.teams.addMember")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
|
||||
<Table.Th
|
||||
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
|
||||
fz="sm"
|
||||
>
|
||||
{t("workspace.people.user")}
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{ fontWeight: 600, color: "var(--mantine-color-gray-7)" }}
|
||||
fz="sm"
|
||||
w={100}
|
||||
>
|
||||
{t("workspace.people.role")}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t("workspace.teams.noMembers", "No members in this team")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teamUsers.map((user) => {
|
||||
const isActive =
|
||||
userLastRequest[user.username] &&
|
||||
Date.now() - userLastRequest[user.username] < 5 * 60 * 1000; // Active within last 5 minutes
|
||||
|
||||
return (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t("workspace.people.disabled", "Disabled")
|
||||
: isActive
|
||||
? t(
|
||||
"workspace.people.activeSession",
|
||||
"Active session",
|
||||
)
|
||||
: t("workspace.people.active", "Active")
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
border: isActive
|
||||
? "2px solid var(--mantine-color-green-6)"
|
||||
: "none",
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Tooltip
|
||||
label={user.username}
|
||||
disabled={user.username.length <= 20}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{isLockedUser(user) && (
|
||||
<Badge color="orange" variant="light" size="xs">
|
||||
{t("workspace.people.lockedBadge", "Locked")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{user.email && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ lineHeight: 1.3 }}
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={
|
||||
(user.rolesAsString || "").includes("ROLE_ADMIN")
|
||||
? "blue"
|
||||
: "cyan"
|
||||
}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || "").includes("ROLE_ADMIN")
|
||||
? t("workspace.people.admin")
|
||||
: t("workspace.people.member")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={500}>
|
||||
Authentication:{" "}
|
||||
{user.authenticationType || "Unknown"}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Last Activity:{" "}
|
||||
{userLastRequest[user.username]
|
||||
? new Date(
|
||||
userLastRequest[user.username],
|
||||
).toLocaleString()
|
||||
: "Never"}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
position="left"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<LocalIcon
|
||||
icon="more-vert"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="swap-horiz"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => openChangeTeamModal(user)}
|
||||
disabled={processing || team.name === "Internal"}
|
||||
>
|
||||
{t(
|
||||
"workspace.teams.changeTeam.label",
|
||||
"Change Team",
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="lock"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => openChangePasswordModal(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t(
|
||||
"workspace.people.changePassword.action",
|
||||
"Change password",
|
||||
)}
|
||||
</Menu.Item>
|
||||
{isLockedUser(user) && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="lock-open"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => handleUnlockUser(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t(
|
||||
"workspace.people.unlockAccount",
|
||||
"Unlock Account",
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{team.name !== "Internal" &&
|
||||
team.name !== "Default" && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="person-remove"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => handleRemoveMember(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t(
|
||||
"workspace.teams.removeMember",
|
||||
"Remove from team",
|
||||
)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="delete"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={processing || team.name === "Internal"}
|
||||
>
|
||||
{t("workspace.people.deleteUser", "Delete User")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<ChangeUserPasswordModal
|
||||
opened={changePasswordModalOpened}
|
||||
onClose={closeChangePasswordModal}
|
||||
user={passwordUser}
|
||||
onSuccess={fetchTeamDetails}
|
||||
mailEnabled={mailEnabled}
|
||||
/>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
onClose={() => setAddMemberModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setAddMemberModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="person-add"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.teams.addMemberToTeam.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
|
||||
<strong>{team.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t("workspace.teams.addMemberToTeam.selectUser")}
|
||||
placeholder={t(
|
||||
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
|
||||
)}
|
||||
data={availableUsers.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
|
||||
}))}
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value || "")}
|
||||
searchable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedUserId &&
|
||||
availableUsers.find((u) => u.id.toString() === selectedUserId)
|
||||
?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAddMember}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.teams.addMemberToTeam.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Change Team Modal */}
|
||||
<Modal
|
||||
opened={changeTeamModalOpened}
|
||||
onClose={() => setChangeTeamModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setChangeTeamModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="swap-horiz"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.teams.changeTeam.title", "Change Team")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("workspace.teams.changeTeam.changing", "Moving")}{" "}
|
||||
<strong>{selectedUser?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t("workspace.teams.changeTeam.selectTeam", "Select Team")}
|
||||
placeholder={t(
|
||||
"workspace.teams.changeTeam.selectTeamPlaceholder",
|
||||
"Choose a team",
|
||||
)}
|
||||
data={allTeams
|
||||
.filter((t) => t.name !== "Internal")
|
||||
.map((team) => ({
|
||||
value: team.id.toString(),
|
||||
label: team.name,
|
||||
}))}
|
||||
value={selectedTeamId}
|
||||
onChange={(value) => setSelectedTeamId(value || "")}
|
||||
searchable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleChangeTeam}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.teams.changeTeam.submit", "Change Team")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
TextInput,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Badge,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { teamService, Team } from "@app/services/teamService";
|
||||
import {
|
||||
userManagementService,
|
||||
User,
|
||||
} from "@app/services/userManagementService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
|
||||
|
||||
export default function TeamsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||
const [renameModalOpened, setRenameModalOpened] = useState(false);
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
|
||||
|
||||
// Form states
|
||||
const [newTeamName, setNewTeamName] = useState("");
|
||||
const [renameTeamName, setRenameTeamName] = useState("");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeams();
|
||||
}, []);
|
||||
|
||||
const fetchTeams = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (loginEnabled) {
|
||||
const teamsData = await teamService.getTeams();
|
||||
setTeams(teamsData);
|
||||
} else {
|
||||
// Provide example data when login is disabled
|
||||
const exampleTeams: Team[] = [
|
||||
{ id: 1, name: "Engineering", userCount: 3 },
|
||||
{ id: 2, name: "Marketing", userCount: 2 },
|
||||
{ id: 3, name: "Internal", userCount: 1 },
|
||||
];
|
||||
setTeams(exampleTeams);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch teams:", error);
|
||||
alert({ alertType: "error", title: "Failed to load teams" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
if (!newTeamName.trim()) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.createTeam.nameRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.createTeam(newTeamName);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.teams.createTeam.success"),
|
||||
});
|
||||
setNewTeamName("");
|
||||
setCreateModalOpened(false);
|
||||
await fetchTeams();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to create team:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.teams.createTeam.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameTeam = async () => {
|
||||
if (!selectedTeam || !renameTeamName.trim()) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.renameTeam.nameRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.renameTeam(selectedTeam.id, renameTeamName);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.teams.renameTeam.success"),
|
||||
});
|
||||
setRenameTeamName("");
|
||||
setSelectedTeam(null);
|
||||
setRenameModalOpened(false);
|
||||
await fetchTeams();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to rename team:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.teams.renameTeam.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (team.name === "Internal") {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.cannotDeleteInternal"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t("workspace.teams.confirmDelete"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await teamService.deleteTeam(team.id);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.teams.deleteTeam.success"),
|
||||
});
|
||||
await fetchTeams();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to delete team:", error);
|
||||
const errorMessage = isAxiosError(error)
|
||||
? error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message
|
||||
: (error instanceof Error ? error.message : undefined) ||
|
||||
t("workspace.teams.deleteTeam.error");
|
||||
alert({ alertType: "error", title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openRenameModal = (team: Team) => {
|
||||
if (team.name === "Internal") {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.cannotRenameInternal"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTeam(team);
|
||||
setRenameTeamName(team.name);
|
||||
setRenameModalOpened(true);
|
||||
};
|
||||
|
||||
const openAddMemberModal = async (team: Team) => {
|
||||
if (team.name === "Internal") {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.cannotAddToInternal"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTeam(team);
|
||||
try {
|
||||
// Fetch all users to show in dropdown
|
||||
const adminData = await userManagementService.getUsers();
|
||||
setAvailableUsers(adminData.users);
|
||||
setAddMemberModalOpened(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.addMemberToTeam.error"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedTeam || !selectedUserId) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.addMemberToTeam.userRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.addUserToTeam(
|
||||
selectedTeam.id,
|
||||
parseInt(selectedUserId),
|
||||
);
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("workspace.teams.addMemberToTeam.success"),
|
||||
});
|
||||
setSelectedUserId("");
|
||||
setSelectedTeam(null);
|
||||
setAddMemberModalOpened(false);
|
||||
await fetchTeams();
|
||||
} catch (error) {
|
||||
console.error("Failed to add member to team:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("workspace.teams.addMemberToTeam.error"),
|
||||
});
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing team details, render TeamDetailsSection
|
||||
if (viewingTeamId !== null) {
|
||||
return (
|
||||
<TeamDetailsSection
|
||||
teamId={viewingTeamId}
|
||||
onBack={() => {
|
||||
setViewingTeamId(null);
|
||||
fetchTeams(); // Refresh teams list
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("workspace.teams.loading", "Loading teams...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<LoginRequiredBanner show={!loginEnabled} />
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("workspace.teams.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("workspace.teams.description")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Header Actions */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
|
||||
onClick={() => setCreateModalOpened(true)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.teams.createNewTeam")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Teams Table */}
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{t("workspace.teams.teamName")}
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{t("workspace.teams.totalMembers")}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teams.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t("workspace.teams.noTeamsFound")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<Table.Tr
|
||||
key={team.id}
|
||||
style={{ cursor: loginEnabled ? "pointer" : "default" }}
|
||||
onClick={() => loginEnabled && setViewingTeamId(team.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={team.name}
|
||||
disabled={team.name.length <= 20}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{team.name === "Internal" && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t("workspace.teams.system")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{team.userCount || 0}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="more-vert"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="visibility"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => setViewingTeamId(team.id)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.teams.viewTeam", "View Team")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon icon="group" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => openAddMemberModal(team)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.teams.addMember")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => openRenameModal(team)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("workspace.teams.renameTeamLabel")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<LocalIcon icon="delete" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
disabled={!loginEnabled || team.name === "Internal"}
|
||||
>
|
||||
{t("workspace.teams.deleteTeamLabel")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Create Team Modal */}
|
||||
<Modal
|
||||
opened={createModalOpened}
|
||||
onClose={() => setCreateModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setCreateModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="group-add"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.teams.createTeam.title")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<TextInput
|
||||
label={t("workspace.teams.createTeam.teamName")}
|
||||
placeholder={t("workspace.teams.createTeam.teamNamePlaceholder")}
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleCreateTeam}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.teams.createTeam.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Rename Team Modal */}
|
||||
<Modal
|
||||
opened={renameModalOpened}
|
||||
onClose={() => setRenameModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setRenameModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="edit"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.teams.renameTeam.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("workspace.teams.renameTeam.renaming")}{" "}
|
||||
<strong>{selectedTeam?.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<TextInput
|
||||
label={t("workspace.teams.renameTeam.newTeamName")}
|
||||
placeholder={t(
|
||||
"workspace.teams.renameTeam.newTeamNamePlaceholder",
|
||||
)}
|
||||
value={renameTeamName}
|
||||
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleRenameTeam}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.teams.renameTeam.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
onClose={() => setAddMemberModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<CloseButton
|
||||
onClick={() => setAddMemberModalOpened(false)}
|
||||
size="lg"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
right: -8,
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Header with Icon */}
|
||||
<Stack gap="md" align="center">
|
||||
<LocalIcon
|
||||
icon="person-add"
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
style={{ color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t("workspace.teams.addMemberToTeam.title")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("workspace.teams.addMemberToTeam.addingTo")}{" "}
|
||||
<strong>{selectedTeam?.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t("workspace.teams.addMemberToTeam.selectUser")}
|
||||
placeholder={t(
|
||||
"workspace.teams.addMemberToTeam.selectUserPlaceholder",
|
||||
)}
|
||||
data={availableUsers.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t("workspace.teams.addMemberToTeam.currentlyIn")} ${user.team.name})` : ""}`,
|
||||
}))}
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value || "")}
|
||||
searchable
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedUserId &&
|
||||
availableUsers.find((u) => u.id.toString() === selectedUserId)
|
||||
?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t("workspace.teams.addMemberToTeam.willBeMoved")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleAddMember}
|
||||
loading={processing}
|
||||
fullWidth
|
||||
size="md"
|
||||
mt="md"
|
||||
>
|
||||
{t("workspace.teams.addMemberToTeam.submit")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { Box, Button, Group, Paper } from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ApiKeySectionProps {
|
||||
publicKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (text: string, tag: string) => void;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ApiKeySection({
|
||||
publicKey,
|
||||
copied,
|
||||
onCopy,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: ApiKeySectionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
radius="md"
|
||||
p={18}
|
||||
style={{
|
||||
background: "var(--api-keys-card-bg)",
|
||||
border: "1px solid var(--api-keys-card-border)",
|
||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
||||
}}
|
||||
>
|
||||
<Group align="flex-end" wrap="nowrap">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Box
|
||||
style={{
|
||||
background: "var(--api-keys-input-bg)",
|
||||
border: "1px solid var(--api-keys-input-border)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
minHeight: 36,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
aria-label={t(
|
||||
"config.apiKeys.publicKeyAriaLabel",
|
||||
"Public API key",
|
||||
)}
|
||||
>
|
||||
<FitText text={publicKey} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => onCopy(publicKey, "public")}
|
||||
leftSection={
|
||||
<LocalIcon icon="content-copy-rounded" width={14} height={14} />
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--api-keys-button-bg)",
|
||||
color: "var(--api-keys-button-color)",
|
||||
border: "none",
|
||||
marginLeft: 12,
|
||||
},
|
||||
}}
|
||||
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
|
||||
>
|
||||
{copied === "public"
|
||||
? t("common.copied", "Copied!")
|
||||
: t("common.copy", "Copy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={onRefresh}
|
||||
leftSection={
|
||||
<LocalIcon icon="refresh-rounded" width={14} height={14} />
|
||||
}
|
||||
styles={{
|
||||
root: {
|
||||
background: "var(--api-keys-button-bg)",
|
||||
color: "var(--api-keys-button-color)",
|
||||
border: "none",
|
||||
marginLeft: 8,
|
||||
},
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
|
||||
>
|
||||
{t("common.refresh", "Refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { Modal, Stack, Text, Group, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface RefreshModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export default function RefreshModal({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: RefreshModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("config.apiKeys.refreshModal.title", "Refresh API Keys")}
|
||||
centered
|
||||
size="sm"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="red">
|
||||
{t(
|
||||
"config.apiKeys.refreshModal.warning",
|
||||
"⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"config.apiKeys.refreshModal.impact",
|
||||
"Any applications or services currently using these keys will stop working until you update them with the new keys.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"config.apiKeys.refreshModal.confirmPrompt",
|
||||
"Are you sure you want to continue?",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={onConfirm}>
|
||||
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useApiKey() {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
function failedToCreateAlert() {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
|
||||
body: t(
|
||||
"config.apiKeys.alert.failedToCreateApiKey",
|
||||
"Failed to create API key.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
|
||||
const fetchKey = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
// Backend is POST for get and update
|
||||
await apiClient
|
||||
.post("/api/v1/user/get-api-key", undefined, {
|
||||
responseType: "json",
|
||||
})
|
||||
.then((response) => {
|
||||
const data = response.data;
|
||||
const apiKeyValue = typeof data === "string" ? data : data?.apiKey;
|
||||
if (typeof apiKeyValue === "string") {
|
||||
setApiKey(apiKeyValue);
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
|
||||
body: t(
|
||||
"config.apiKeys.alert.failedToRetrieveApiKey",
|
||||
"Failed to retrieve API key from response.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(async (e) => {
|
||||
// If not found, try to create one by calling update endpoint
|
||||
if (e?.response?.status === 404) {
|
||||
await apiClient
|
||||
.post("/api/v1/user/update-api-key")
|
||||
.then((createRes) => {
|
||||
const created =
|
||||
typeof createRes.data === "string"
|
||||
? createRes.data
|
||||
: createRes.data?.apiKey;
|
||||
if (typeof created === "string") {
|
||||
setApiKey(created);
|
||||
} else {
|
||||
failedToCreateAlert();
|
||||
}
|
||||
})
|
||||
.catch((createErr) => {
|
||||
failedToCreateAlert();
|
||||
setError(createErr);
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
|
||||
body: t(
|
||||
"config.apiKeys.alert.failedToFetchApiKey",
|
||||
"Failed to fetch API key.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
setError(e);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
await apiClient
|
||||
.post("/api/v1/user/update-api-key", undefined, {
|
||||
responseType: "json",
|
||||
suppressErrorToast: true,
|
||||
})
|
||||
.then((res) => {
|
||||
const value =
|
||||
typeof res.data === "string" ? res.data : res.data?.apiKey;
|
||||
if (typeof value === "string") {
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t(
|
||||
"config.apiKeys.alert.apiKeyRefreshed",
|
||||
"API Key Refreshed",
|
||||
),
|
||||
body: t(
|
||||
"config.apiKeys.alert.apiKeyRefreshedBody",
|
||||
"Your API key has been successfully refreshed.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
setApiKey(value);
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
|
||||
body: t(
|
||||
"config.apiKeys.alert.failedToRefreshApiKey",
|
||||
"Failed to refresh API key.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("config.apiKeys.alert.apiKeyErrorTitle", "API Key Error"),
|
||||
body: t(
|
||||
"config.apiKeys.alert.failedToRefreshApiKey",
|
||||
"Failed to refresh API key.",
|
||||
),
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
setError(e);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRefreshing(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAttempted) {
|
||||
fetchKey();
|
||||
}
|
||||
}, [hasAttempted, fetchKey]);
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
refetch: fetchKey,
|
||||
refresh,
|
||||
hasAttempted,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default useApiKey;
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
Loader,
|
||||
Alert,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import auditService, { AuditChartsData } from "@app/services/auditService";
|
||||
|
||||
// Event type color mapping
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
USER_LOGIN: "var(--mantine-color-green-6)",
|
||||
USER_LOGOUT: "var(--mantine-color-gray-5)",
|
||||
USER_FAILED_LOGIN: "var(--mantine-color-red-6)",
|
||||
USER_PROFILE_UPDATE: "var(--mantine-color-blue-6)",
|
||||
SETTINGS_CHANGED: "var(--mantine-color-orange-6)",
|
||||
FILE_OPERATION: "var(--mantine-color-cyan-6)",
|
||||
PDF_PROCESS: "var(--mantine-color-violet-6)",
|
||||
UI_DATA: "var(--mantine-color-teal-6)",
|
||||
HTTP_REQUEST: "var(--mantine-color-indigo-6)",
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string): string => {
|
||||
return EVENT_TYPE_COLORS[type] || "var(--mantine-color-blue-6)";
|
||||
};
|
||||
|
||||
interface AuditChartsSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
timePeriod?: "day" | "week" | "month";
|
||||
onTimePeriodChange?: (period: "day" | "week" | "month") => void;
|
||||
}
|
||||
|
||||
const AuditChartsSection: React.FC<AuditChartsSectionProps> = ({
|
||||
loginEnabled = true,
|
||||
timePeriod = "week",
|
||||
onTimePeriodChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [chartsData, setChartsData] = useState<AuditChartsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChartsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getChartsData(timePeriod);
|
||||
setChartsData(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("audit.charts.error", "Failed to load charts"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled) {
|
||||
fetchChartsData();
|
||||
} else {
|
||||
// Demo data when login disabled
|
||||
setChartsData({
|
||||
eventsByType: {
|
||||
labels: [
|
||||
"LOGIN",
|
||||
"LOGOUT",
|
||||
"SETTINGS_CHANGE",
|
||||
"FILE_UPLOAD",
|
||||
"FILE_DOWNLOAD",
|
||||
],
|
||||
values: [342, 289, 145, 678, 523],
|
||||
},
|
||||
eventsByUser: {
|
||||
labels: ["admin", "user1", "user2", "user3", "user4"],
|
||||
values: [456, 321, 287, 198, 165],
|
||||
},
|
||||
eventsOverTime: {
|
||||
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
values: [123, 145, 167, 189, 201, 87, 65],
|
||||
},
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timePeriod, loginEnabled]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="center">
|
||||
<Loader size="lg" my="xl" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("audit.charts.error", "Error loading charts")}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chartsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform data for Recharts
|
||||
const eventsOverTimeData = chartsData.eventsOverTime.labels.map(
|
||||
(label, index) => ({
|
||||
name: label,
|
||||
value: chartsData.eventsOverTime.values[index],
|
||||
}),
|
||||
);
|
||||
|
||||
const eventsByTypeData = chartsData.eventsByType.labels.map(
|
||||
(label, index) => ({
|
||||
type: label,
|
||||
value: chartsData.eventsByType.values[index],
|
||||
}),
|
||||
);
|
||||
|
||||
const eventsByUserData = chartsData.eventsByUser.labels.map(
|
||||
(label, index) => ({
|
||||
user: label,
|
||||
value: chartsData.eventsByUser.values[index],
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header with time period selector */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("audit.charts.title", "Audit Dashboard")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={timePeriod}
|
||||
onChange={(value) => {
|
||||
onTimePeriodChange?.(value as "day" | "week" | "month");
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
data={[
|
||||
{ label: t("audit.charts.day", "Day"), value: "day" },
|
||||
{ label: t("audit.charts.week", "Week"), value: "week" },
|
||||
{ label: t("audit.charts.month", "Month"), value: "month" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Full-width Events Over Time Chart */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t("audit.charts.overTime", "Events Over Time")}
|
||||
</Text>
|
||||
<Box style={{ width: "100%", height: 280 }}>
|
||||
{eventsOverTimeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={eventsOverTimeData}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--mantine-color-blue-6)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--mantine-color-blue-6)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
/>
|
||||
<XAxis dataKey="name" stroke="var(--mantine-color-gray-6)" />
|
||||
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--mantine-color-gray-8)",
|
||||
border: "none",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
color: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="var(--mantine-color-blue-6)"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorValue)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">
|
||||
{t("audit.charts.noData", "No data for this period")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Two-column grid for remaining charts */}
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
{/* Events by Type Chart */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t("audit.charts.byType", "Events by Type")}
|
||||
</Text>
|
||||
<Box style={{ width: "100%", height: 280 }}>
|
||||
{eventsByTypeData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={eventsByTypeData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="type"
|
||||
stroke="var(--mantine-color-gray-6)"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis stroke="var(--mantine-color-gray-6)" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--mantine-color-gray-8)",
|
||||
border: "none",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
color: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="var(--mantine-color-blue-6)">
|
||||
{eventsByTypeData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={getEventTypeColor(entry.type)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Top Users Chart (Horizontal) */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="md" fw={600}>
|
||||
{t("audit.charts.byUser", "Top Users")}
|
||||
</Text>
|
||||
<Box style={{ width: "100%", height: 280 }}>
|
||||
{eventsByUserData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={eventsByUserData} layout="vertical">
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
/>
|
||||
<XAxis type="number" stroke="var(--mantine-color-gray-6)" />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="user"
|
||||
stroke="var(--mantine-color-gray-6)"
|
||||
width={100}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--mantine-color-gray-8)",
|
||||
border: "none",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
color: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill="var(--mantine-color-green-6)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Group justify="center">
|
||||
<Text c="dimmed">{t("audit.charts.noData", "No data")}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditChartsSection;
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Stack,
|
||||
Text,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Group,
|
||||
Alert,
|
||||
Code,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import auditService from "@app/services/auditService";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface AuditClearDataSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AuditClearDataSection: React.FC<AuditClearDataSectionProps> = ({
|
||||
loginEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [confirmationCode, setConfirmationCode] = useState("");
|
||||
const [generatedCode, setGeneratedCode] = useState("");
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleInitiateDeletion = () => {
|
||||
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||
setGeneratedCode(code);
|
||||
setConfirmationCode("");
|
||||
setShowConfirmation(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setConfirmationCode("");
|
||||
setGeneratedCode("");
|
||||
setShowConfirmation(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClearData = async () => {
|
||||
if (confirmationCode !== generatedCode) {
|
||||
setError(t("audit.clearData.codeDoesNotMatch", "Code does not match"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setClearing(true);
|
||||
setError(null);
|
||||
await auditService.clearAllAuditData();
|
||||
setSuccess(true);
|
||||
resetForm();
|
||||
// Auto-dismiss success message after 5 seconds
|
||||
setTimeout(() => setSuccess(false), 5000);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to clear audit data",
|
||||
);
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="green"
|
||||
icon={
|
||||
<LocalIcon icon="check-circle" width="1.2rem" height="1.2rem" />
|
||||
}
|
||||
title={t("audit.clearData.success", "Success")}
|
||||
onClose={() => setSuccess(false)}
|
||||
closeButtonLabel="Close alert"
|
||||
withCloseButton
|
||||
>
|
||||
{t(
|
||||
"audit.clearData.successMessage",
|
||||
"All audit data has been cleared successfully",
|
||||
)}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||
title={t(
|
||||
"audit.clearData.confirmTitle",
|
||||
"Please confirm you want to delete",
|
||||
)}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"audit.clearData.confirmMessage",
|
||||
"This will permanently remove all audit logs. Enter the confirmation code below to proceed.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-red-4)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-gray-0)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed" mb="xs">
|
||||
{t("audit.clearData.confirmationCode", "Confirmation Code")}
|
||||
</Text>
|
||||
<Code
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
letterSpacing: "0.15em",
|
||||
fontWeight: 600,
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
padding: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{generatedCode}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t(
|
||||
"audit.clearData.enterCodeBelow",
|
||||
"Enter the code exactly as shown above (case-sensitive)",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label={t("audit.clearData.enterCode", "Confirmation Code")}
|
||||
placeholder={t(
|
||||
"audit.clearData.codePlaceholder",
|
||||
"Type the code here",
|
||||
)}
|
||||
value={confirmationCode}
|
||||
onChange={(e) => setConfirmationCode(e.currentTarget.value)}
|
||||
disabled={!loginEnabled}
|
||||
error={
|
||||
confirmationCode && confirmationCode !== generatedCode
|
||||
? t("audit.clearData.codeDoesNotMatch", "Code does not match")
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<LocalIcon icon="error" width="1.2rem" height="1.2rem" />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="default" onClick={resetForm} disabled={clearing}>
|
||||
{t("audit.clearData.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleClearData}
|
||||
loading={clearing}
|
||||
disabled={
|
||||
!loginEnabled ||
|
||||
!generatedCode ||
|
||||
confirmationCode !== generatedCode
|
||||
}
|
||||
>
|
||||
{t("audit.clearData.deleteButton", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}
|
||||
title={t("audit.clearData.warning1", "This action cannot be undone")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"audit.clearData.warning2",
|
||||
"Deleting audit data will permanently remove all historical audit logs, including security events, user activities, and file operations from the database.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-red-4)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Text fw={600}>
|
||||
{t(
|
||||
"audit.clearData.confirmationRequired",
|
||||
"Delete All Audit Data",
|
||||
)}
|
||||
</Text>
|
||||
<Badge color="red">
|
||||
{t("audit.clearData.irreversible", "IRREVERSIBLE")}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleInitiateDeletion}
|
||||
disabled={!loginEnabled}
|
||||
fullWidth
|
||||
>
|
||||
{t("audit.clearData.initiateDelete", "Delete All Data")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditClearDataSection;
|
||||
+564
@@ -0,0 +1,564 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
Pagination,
|
||||
Modal,
|
||||
Code,
|
||||
Loader,
|
||||
Alert,
|
||||
Table,
|
||||
Badge,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import auditService, {
|
||||
AuditEvent,
|
||||
AuditFilters,
|
||||
} from "@app/services/auditService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { useAuditFilters } from "@app/hooks/useAuditFilters";
|
||||
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface AuditEventsTableProps {
|
||||
loginEnabled?: boolean;
|
||||
captureFileHash?: boolean;
|
||||
capturePdfAuthor?: boolean;
|
||||
}
|
||||
|
||||
const AuditEventsTable: React.FC<AuditEventsTableProps> = ({
|
||||
loginEnabled = true,
|
||||
captureFileHash = false,
|
||||
capturePdfAuthor = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);
|
||||
const [sortKey, setSortKey] = useState<
|
||||
"timestamp" | "eventType" | "username" | "ipAddress" | null
|
||||
>("timestamp");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const showAuthor = capturePdfAuthor;
|
||||
const showFileHash = captureFileHash;
|
||||
const totalColumns = 5 + (showAuthor ? 1 : 0) + (showFileHash ? 1 : 0);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
|
||||
useAuditFilters(
|
||||
{
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
},
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await auditService.getEvents({
|
||||
...filters,
|
||||
page: currentPage - 1,
|
||||
});
|
||||
setEvents(response.events);
|
||||
setTotalPages(response.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled) {
|
||||
fetchEvents();
|
||||
} else {
|
||||
// Provide example audit events when login is disabled
|
||||
const now = new Date();
|
||||
setEvents([
|
||||
{
|
||||
id: "1",
|
||||
timestamp: new Date(now.getTime() - 1000 * 60 * 15).toISOString(),
|
||||
eventType: "LOGIN",
|
||||
username: "admin",
|
||||
ipAddress: "192.168.1.100",
|
||||
details: { message: "User logged in successfully" },
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
timestamp: new Date(now.getTime() - 1000 * 60 * 30).toISOString(),
|
||||
eventType: "FILE_UPLOAD",
|
||||
username: "user1",
|
||||
ipAddress: "192.168.1.101",
|
||||
details: { message: "Uploaded document.pdf" },
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
timestamp: new Date(now.getTime() - 1000 * 60 * 45).toISOString(),
|
||||
eventType: "SETTINGS_CHANGE",
|
||||
username: "admin",
|
||||
ipAddress: "192.168.1.100",
|
||||
details: { message: "Modified system settings" },
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
timestamp: new Date(now.getTime() - 1000 * 60 * 60).toISOString(),
|
||||
eventType: "FILE_DOWNLOAD",
|
||||
username: "user2",
|
||||
ipAddress: "192.168.1.102",
|
||||
details: { message: "Downloaded report.pdf" },
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
timestamp: new Date(now.getTime() - 1000 * 60 * 90).toISOString(),
|
||||
eventType: "LOGOUT",
|
||||
username: "user1",
|
||||
ipAddress: "192.168.1.101",
|
||||
details: { message: "User logged out" },
|
||||
},
|
||||
]);
|
||||
setTotalPages(1);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, currentPage, loginEnabled]);
|
||||
|
||||
// Wrap filter handlers to reset pagination
|
||||
const handleFilterChangeWithReset = (
|
||||
key: keyof AuditFilters,
|
||||
value: AuditFilters[keyof AuditFilters],
|
||||
) => {
|
||||
handleFilterChange(key, value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleClearFiltersWithReset = () => {
|
||||
handleClearFilters();
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Sort handling
|
||||
const toggleSort = (
|
||||
key: "timestamp" | "eventType" | "username" | "ipAddress",
|
||||
) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (
|
||||
key: "timestamp" | "eventType" | "username" | "ipAddress",
|
||||
) => {
|
||||
if (sortKey !== key) return "unfold-more";
|
||||
return sortDir === "asc" ? "expand-less" : "expand-more";
|
||||
};
|
||||
|
||||
// Event type colors
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
USER_LOGIN: "green",
|
||||
USER_LOGOUT: "gray",
|
||||
USER_FAILED_LOGIN: "red",
|
||||
USER_PROFILE_UPDATE: "blue",
|
||||
SETTINGS_CHANGED: "orange",
|
||||
FILE_OPERATION: "cyan",
|
||||
PDF_PROCESS: "violet",
|
||||
UI_DATA: "gray",
|
||||
HTTP_REQUEST: "indigo",
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string): string => {
|
||||
return EVENT_TYPE_COLORS[type] || "blue";
|
||||
};
|
||||
|
||||
// Apply sorting to current events
|
||||
const sortedEvents = [...events].sort((a, b) => {
|
||||
let aVal: string | number | undefined;
|
||||
let bVal: string | number | undefined;
|
||||
|
||||
switch (sortKey) {
|
||||
case "timestamp":
|
||||
aVal = new Date(a.timestamp).getTime();
|
||||
bVal = new Date(b.timestamp).getTime();
|
||||
break;
|
||||
case "eventType":
|
||||
aVal = a.eventType;
|
||||
bVal = b.eventType;
|
||||
break;
|
||||
case "username":
|
||||
aVal = a.username;
|
||||
bVal = b.username;
|
||||
break;
|
||||
case "ipAddress":
|
||||
aVal = a.ipAddress;
|
||||
bVal = b.ipAddress;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("audit.events.title", "Audit Events")}
|
||||
</Text>
|
||||
|
||||
{/* Filters */}
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChangeWithReset}
|
||||
onClearFilters={handleClearFiltersWithReset}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<Loader size="lg" my="xl" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("audit.events.error", "Error loading events")}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr
|
||||
style={{ backgroundColor: "var(--mantine-color-gray-0)" }}
|
||||
>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => toggleSort("timestamp")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("audit.events.timestamp", "Timestamp")}
|
||||
<LocalIcon
|
||||
icon={getSortIcon("timestamp")}
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => toggleSort("eventType")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("audit.events.type", "Type")}
|
||||
<LocalIcon
|
||||
icon={getSortIcon("eventType")}
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
padding: "0.5rem",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => toggleSort("username")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{t("audit.events.user", "User")}
|
||||
<LocalIcon
|
||||
icon={getSortIcon("username")}
|
||||
width="0.9rem"
|
||||
height="0.9rem"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
{t("audit.events.documentName", "Document Name")}
|
||||
</Table.Th>
|
||||
{showAuthor && (
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
{t("audit.events.author", "Author")}
|
||||
</Table.Th>
|
||||
)}
|
||||
{showFileHash && (
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
>
|
||||
{t("audit.events.fileHash", "File Hash")}
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
ta="center"
|
||||
>
|
||||
{t("audit.events.actions", "Actions")}
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sortedEvents.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={totalColumns}>
|
||||
<Group justify="center" py="xl">
|
||||
<Stack align="center" gap={0}>
|
||||
<LocalIcon
|
||||
icon="search"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
<Text ta="center" c="dimmed" size="sm">
|
||||
{t("audit.events.noEvents", "No events found")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
sortedEvents.map((event) => {
|
||||
// Extract document name, author, hash from details.files if available
|
||||
let documentName = "";
|
||||
let author = "";
|
||||
let fileHash = "";
|
||||
if (event.details && typeof event.details === "object") {
|
||||
const details = event.details as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const files = details.files;
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
const firstFile = files[0] as Record<string, unknown>;
|
||||
documentName =
|
||||
typeof firstFile.name === "string"
|
||||
? firstFile.name
|
||||
: "";
|
||||
if (showAuthor || showFileHash) {
|
||||
author =
|
||||
typeof firstFile.pdfAuthor === "string"
|
||||
? firstFile.pdfAuthor
|
||||
: "";
|
||||
fileHash =
|
||||
typeof firstFile.fileHash === "string"
|
||||
? firstFile.fileHash.substring(0, 16) + "..."
|
||||
: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Tr key={event.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{formatDate(event.timestamp)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
size="sm"
|
||||
color={getEventTypeColor(event.eventType)}
|
||||
>
|
||||
{event.eventType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{event.username}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" title={documentName}>
|
||||
{documentName || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{showAuthor && (
|
||||
<Table.Td>
|
||||
<Text size="sm">{author}</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
{showFileHash && (
|
||||
<Table.Td>
|
||||
<Text
|
||||
size="sm"
|
||||
title={fileHash}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{fileHash}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td ta="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
disabled={!loginEnabled}
|
||||
>
|
||||
{t("audit.events.viewDetails", "View Details")}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
total={totalPages}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
<Modal
|
||||
opened={selectedEvent !== null}
|
||||
onClose={() => setSelectedEvent(null)}
|
||||
title={t("audit.events.eventDetails", "Event Details")}
|
||||
size="lg"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{selectedEvent && (
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("audit.events.timestamp", "Timestamp")}
|
||||
</Text>
|
||||
<Text size="sm">{formatDate(selectedEvent.timestamp)}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("audit.events.type", "Type")}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.eventType}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("audit.events.user", "User")}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.username}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("audit.events.ipAddress", "IP Address")}
|
||||
</Text>
|
||||
<Text size="sm">{selectedEvent.ipAddress}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("audit.events.details", "Details")}
|
||||
</Text>
|
||||
<Code block mah={300} style={{ overflow: "auto" }}>
|
||||
{JSON.stringify(selectedEvent.details, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditEventsTable;
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Group,
|
||||
Stack,
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import auditService from "@app/services/auditService";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useAuditFilters } from "@app/hooks/useAuditFilters";
|
||||
import AuditFiltersForm from "@app/components/shared/config/configSections/audit/AuditFiltersForm";
|
||||
|
||||
interface AuditExportSectionProps {
|
||||
loginEnabled?: boolean;
|
||||
captureFileHash?: boolean;
|
||||
capturePdfAuthor?: boolean;
|
||||
captureOperationResults?: boolean;
|
||||
}
|
||||
|
||||
const AuditExportSection: React.FC<AuditExportSectionProps> = ({
|
||||
loginEnabled = true,
|
||||
captureFileHash = false,
|
||||
capturePdfAuthor = false,
|
||||
captureOperationResults = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [exportFormat, setExportFormat] = useState<"csv" | "json">("csv");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedFields, setSelectedFields] = useState<Record<string, boolean>>(
|
||||
{
|
||||
date: true,
|
||||
username: true,
|
||||
ipaddress: false,
|
||||
tool: true,
|
||||
documentName: true,
|
||||
outcome: true,
|
||||
author: capturePdfAuthor,
|
||||
fileHash: captureFileHash,
|
||||
operationResults: captureOperationResults,
|
||||
},
|
||||
);
|
||||
|
||||
// Use shared filters hook
|
||||
const { filters, eventTypes, users, handleFilterChange, handleClearFilters } =
|
||||
useAuditFilters({}, loginEnabled);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!loginEnabled) return;
|
||||
|
||||
try {
|
||||
setExporting(true);
|
||||
|
||||
const fieldsParam = Object.keys(selectedFields)
|
||||
.filter((k) => selectedFields[k])
|
||||
.join(",");
|
||||
|
||||
const blob = await auditService.exportData(exportFormat, {
|
||||
...filters,
|
||||
fields: fieldsParam,
|
||||
});
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export failed:", err);
|
||||
alert(t("audit.export.error", "Failed to export data"));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("audit.export.title", "Export Audit Data")}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"audit.export.description",
|
||||
"Export audit events to CSV or JSON format. Use filters to limit the exported data.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("audit.export.format", "Export Format")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={exportFormat}
|
||||
onChange={(value) => {
|
||||
if (!loginEnabled) return;
|
||||
setExportFormat(value as "csv" | "json");
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
data={[
|
||||
{ label: "CSV", value: "csv" },
|
||||
{ label: "JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Selection */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("audit.export.selectFields", "Select Fields to Include")}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldDate", "Date")}
|
||||
checked={selectedFields.date}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
date: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldUsername", "Username")}
|
||||
checked={selectedFields.username}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
username: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldIpAddress", "IP Address")}
|
||||
checked={selectedFields.ipaddress}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
ipaddress: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldTool", "Tool")}
|
||||
checked={selectedFields.tool}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
tool: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldDocumentName", "Document Name")}
|
||||
checked={selectedFields.documentName}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
documentName: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t(
|
||||
"audit.export.fieldOutcome",
|
||||
"Outcome (Success/Failure)",
|
||||
)}
|
||||
checked={selectedFields.outcome}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
outcome: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
{capturePdfAuthor && (
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldAuthor", "Author (from PDF)")}
|
||||
checked={selectedFields.author}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
author: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
{captureFileHash && (
|
||||
<Checkbox
|
||||
label={t("audit.export.fieldFileHash", "File Hash (SHA-256)")}
|
||||
checked={selectedFields.fileHash}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
fileHash: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
{captureOperationResults && (
|
||||
<Checkbox
|
||||
label={t(
|
||||
"audit.export.fieldOperationResults",
|
||||
"Operation Results",
|
||||
)}
|
||||
checked={selectedFields.operationResults}
|
||||
onChange={(e) =>
|
||||
setSelectedFields({
|
||||
...selectedFields,
|
||||
operationResults: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("audit.export.filters", "Filters (Optional)")}
|
||||
</Text>
|
||||
<AuditFiltersForm
|
||||
filters={filters}
|
||||
eventTypes={eventTypes}
|
||||
users={users}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={
|
||||
<LocalIcon icon="download" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={!loginEnabled || exporting}
|
||||
>
|
||||
{t("audit.export.exportButton", "Export Data")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditExportSection;
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Group,
|
||||
MultiSelect,
|
||||
Button,
|
||||
Stack,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuditFilters } from "@app/services/auditService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
// Helper to format date as YYYY-MM-DD in local time (avoids DST/UTC issues)
|
||||
const formatDateToYMD = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Helper to calculate date range for quick presets
|
||||
const getDateRange = (preset: string): [Date, Date] | null => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (preset) {
|
||||
case "today":
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return [start, end];
|
||||
case "last7":
|
||||
start.setDate(start.getDate() - 6);
|
||||
return [start, end];
|
||||
case "last30":
|
||||
start.setDate(start.getDate() - 29);
|
||||
return [start, end];
|
||||
case "thisMonth":
|
||||
start.setDate(1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return [start, end];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface AuditFiltersFormProps {
|
||||
filters: AuditFilters;
|
||||
eventTypes: string[];
|
||||
users: string[];
|
||||
onFilterChange: (
|
||||
key: keyof AuditFilters,
|
||||
value: AuditFilters[keyof AuditFilters],
|
||||
) => void;
|
||||
onClearFilters: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter form for audit components
|
||||
*/
|
||||
const AuditFiltersForm: React.FC<AuditFiltersFormProps> = ({
|
||||
filters,
|
||||
eventTypes,
|
||||
users,
|
||||
onFilterChange,
|
||||
onClearFilters,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleQuickPreset = (preset: string) => {
|
||||
const range = getDateRange(preset);
|
||||
if (range) {
|
||||
const [start, end] = range;
|
||||
onFilterChange("startDate", formatDateToYMD(start));
|
||||
onFilterChange("endDate", formatDateToYMD(end));
|
||||
}
|
||||
};
|
||||
|
||||
const isPresetActive = (preset: string): boolean => {
|
||||
if (!filters.startDate || !filters.endDate) return false;
|
||||
const range = getDateRange(preset);
|
||||
if (!range) return false;
|
||||
const [expectedStart, expectedEnd] = range;
|
||||
const expectedStartStr = formatDateToYMD(expectedStart);
|
||||
const expectedEndStr = formatDateToYMD(expectedEnd);
|
||||
return (
|
||||
filters.startDate === expectedStartStr &&
|
||||
filters.endDate === expectedEndStr
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Quick Preset Buttons */}
|
||||
<div>
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
{t("audit.filters.quickPresets", "Quick filters")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant={isPresetActive("today") ? "filled" : "light"}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset("today")}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("audit.filters.today", "Today")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive("last7") ? "filled" : "light"}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset("last7")}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("audit.filters.last7Days", "Last 7 days")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive("last30") ? "filled" : "light"}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset("last30")}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("audit.filters.last30Days", "Last 30 days")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isPresetActive("thisMonth") ? "filled" : "light"}
|
||||
size="xs"
|
||||
onClick={() => handleQuickPreset("thisMonth")}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("audit.filters.thisMonth", "This month")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Filter Inputs */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="sm">
|
||||
<MultiSelect
|
||||
placeholder={t("audit.events.filterByType", "Filter by type")}
|
||||
data={eventTypes.map((type) => ({ value: type, label: type }))}
|
||||
value={
|
||||
Array.isArray(filters.eventType)
|
||||
? filters.eventType
|
||||
: filters.eventType
|
||||
? [filters.eventType]
|
||||
: []
|
||||
}
|
||||
onChange={(value) =>
|
||||
onFilterChange("eventType", value.length > 0 ? value : undefined)
|
||||
}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<MultiSelect
|
||||
placeholder={t("audit.events.filterByUser", "Filter by user")}
|
||||
data={users.map((user) => ({ value: user, label: user }))}
|
||||
value={
|
||||
Array.isArray(filters.username)
|
||||
? filters.username
|
||||
: filters.username
|
||||
? [filters.username]
|
||||
: []
|
||||
}
|
||||
onChange={(value) =>
|
||||
onFilterChange("username", value.length > 0 ? value : undefined)
|
||||
}
|
||||
clearable
|
||||
searchable
|
||||
disabled={disabled}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t("audit.events.startDate", "Start date")}
|
||||
value={filters.startDate ? new Date(filters.startDate) : null}
|
||||
onChange={(value) => {
|
||||
onFilterChange(
|
||||
"startDate",
|
||||
value ? formatDateToYMD(new Date(value)) : undefined,
|
||||
);
|
||||
}}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
popoverProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder={t("audit.events.endDate", "End date")}
|
||||
value={filters.endDate ? new Date(filters.endDate) : null}
|
||||
onChange={(value) => {
|
||||
onFilterChange(
|
||||
"endDate",
|
||||
value ? formatDateToYMD(new Date(value)) : undefined,
|
||||
);
|
||||
}}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
popoverProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Clear Button */}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t("audit.events.clearFilters", "Clear")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditFiltersForm;
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Loader,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import auditService, { AuditStats } from "@app/services/auditService";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface AuditStatsCardsProps {
|
||||
loginEnabled?: boolean;
|
||||
timePeriod: "day" | "week" | "month";
|
||||
}
|
||||
|
||||
const AuditStatsCards: React.FC<AuditStatsCardsProps> = ({
|
||||
loginEnabled = true,
|
||||
timePeriod = "week",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await auditService.getStats(timePeriod);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load statistics",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loginEnabled) {
|
||||
fetchStats();
|
||||
} else {
|
||||
// Demo data when login disabled
|
||||
setStats({
|
||||
totalEvents: 4256,
|
||||
prevTotalEvents: 3891,
|
||||
uniqueUsers: 12,
|
||||
prevUniqueUsers: 10,
|
||||
successRate: 96.5,
|
||||
prevSuccessRate: 94.2,
|
||||
avgLatencyMs: 342,
|
||||
prevAvgLatencyMs: 385,
|
||||
errorCount: 148,
|
||||
topEventType: "PDF_PROCESS",
|
||||
topUser: "admin",
|
||||
eventsByType: {},
|
||||
eventsByUser: {},
|
||||
topTools: {},
|
||||
hourlyDistribution: {},
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timePeriod, loginEnabled]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "2rem 0",
|
||||
}}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title={t("audit.stats.error", "Error loading statistics")}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trendPercent =
|
||||
stats.prevTotalEvents > 0
|
||||
? ((stats.totalEvents - stats.prevTotalEvents) / stats.prevTotalEvents) *
|
||||
100
|
||||
: 0;
|
||||
const userTrend =
|
||||
stats.prevUniqueUsers > 0
|
||||
? ((stats.uniqueUsers - stats.prevUniqueUsers) / stats.prevUniqueUsers) *
|
||||
100
|
||||
: 0;
|
||||
const latencyTrend =
|
||||
stats.prevAvgLatencyMs > 0
|
||||
? ((stats.avgLatencyMs - stats.prevAvgLatencyMs) /
|
||||
stats.prevAvgLatencyMs) *
|
||||
100
|
||||
: 0;
|
||||
const successTrend =
|
||||
stats.prevSuccessRate > 0 ? stats.successRate - stats.prevSuccessRate : 0;
|
||||
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 95) return "green";
|
||||
if (rate >= 80) return "yellow";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number, lowerIsBetter: boolean = false) => {
|
||||
if (lowerIsBetter) {
|
||||
return trend <= 0 ? "green" : "red";
|
||||
}
|
||||
return trend >= 0 ? "green" : "red";
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number, lowerIsBetter: boolean = false) => {
|
||||
const isPositive = lowerIsBetter ? trend <= 0 : trend >= 0;
|
||||
return isPositive ? "trending-up" : "trending-down";
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||
{/* Total Events Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.stats.totalEvents", "Total Events")}
|
||||
</Text>
|
||||
<LocalIcon icon="analytics" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
{trendPercent !== 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(trendPercent)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(trendPercent)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: "0.25rem" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(trendPercent).toFixed(1)}%{" "}
|
||||
{t("audit.stats.vsLastPeriod", "vs last period")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Success Rate Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.stats.successRate", "Success Rate")}
|
||||
</Text>
|
||||
<LocalIcon
|
||||
icon="check-circle-rounded"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.successRate.toFixed(1)}%
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
color={getSuccessRateColor(stats.successRate)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{stats.successRate >= 95
|
||||
? t("audit.stats.excellent", "Excellent")
|
||||
: stats.successRate >= 80
|
||||
? t("audit.stats.good", "Good")
|
||||
: t("audit.stats.attention", "Attention needed")}
|
||||
</Badge>
|
||||
{successTrend !== 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(successTrend)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
>
|
||||
{successTrend > 0 ? "+" : ""}
|
||||
{successTrend.toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Active Users Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.stats.activeUsers", "Active Users")}
|
||||
</Text>
|
||||
<LocalIcon icon="group" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.uniqueUsers}
|
||||
</Text>
|
||||
{userTrend !== 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(userTrend)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(userTrend)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: "0.25rem" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(userTrend).toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Avg Latency Card */}
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.stats.avgLatency", "Avg Latency")}
|
||||
</Text>
|
||||
<LocalIcon icon="speed" width="1.2rem" height="1.2rem" />
|
||||
</Group>
|
||||
<Text size="xl" fw={700}>
|
||||
{stats.avgLatencyMs > 0
|
||||
? `${stats.avgLatencyMs.toFixed(0)}ms`
|
||||
: t("audit.stats.noData", "N/A")}
|
||||
</Text>
|
||||
{latencyTrend !== 0 && stats.avgLatencyMs > 0 && (
|
||||
<Badge
|
||||
color={getTrendColor(latencyTrend, true)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={getTrendIcon(latencyTrend, true)}
|
||||
width="0.8rem"
|
||||
height="0.8rem"
|
||||
style={{ marginRight: "0.25rem" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{Math.abs(latencyTrend).toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditStatsCards;
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import React from "react";
|
||||
import { Card, Group, Stack, Badge, Text, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuditSystemStatus as AuditStatus } from "@app/services/auditService";
|
||||
|
||||
interface AuditSystemStatusProps {
|
||||
status: AuditStatus;
|
||||
}
|
||||
|
||||
const AuditSystemStatus: React.FC<AuditSystemStatusProps> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("audit.systemStatus.title", "System Status")}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.systemStatus.status", "Audit Logging")}
|
||||
</Text>
|
||||
<Badge
|
||||
color={status.enabled ? "green" : "red"}
|
||||
variant="light"
|
||||
size="lg"
|
||||
mt="xs"
|
||||
>
|
||||
{status.enabled
|
||||
? t("audit.systemStatus.enabled", "Enabled")
|
||||
: t("audit.systemStatus.disabled", "Disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.systemStatus.level", "Audit Level")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.level}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.systemStatus.retention", "Retention Period")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.retentionDays} {t("audit.systemStatus.days", "days")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("audit.systemStatus.totalEvents", "Total Events")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600} mt="xs">
|
||||
{status.totalEvents.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("audit.systemStatus.capturedFields", "Captured Fields")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t("audit.systemStatus.username", "Username")}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t("audit.systemStatus.documentName", "Document Name")}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t("audit.systemStatus.tool", "Tool")}
|
||||
</Badge>
|
||||
<Badge color="green" variant="light" size="sm">
|
||||
{t("audit.systemStatus.date", "Date")}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.capturePdfAuthor ? "green" : "gray"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{t("audit.systemStatus.pdfAuthor", "PDF Author")}
|
||||
{!status.capturePdfAuthor && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
(
|
||||
{t(
|
||||
"audit.systemStatus.captureBySettings",
|
||||
"Enable in settings",
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.captureFileHash ? "green" : "gray"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
{t("audit.systemStatus.fileHash", "File Hash")}
|
||||
{!status.captureFileHash && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
(
|
||||
{t(
|
||||
"audit.systemStatus.captureBySettings",
|
||||
"Enable in settings",
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditSystemStatus;
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button, Collapse, Select, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import licenseService, {
|
||||
PlanTier,
|
||||
PlanTierGroup,
|
||||
LicenseInfo,
|
||||
mapLicenseToTier,
|
||||
} from "@app/services/licenseService";
|
||||
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
|
||||
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
isCurrentTier as checkIsCurrentTier,
|
||||
isDowngrade as checkIsDowngrade,
|
||||
} from "@app/utils/planTierUtils";
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlanId?: string;
|
||||
currentLicenseInfo?: LicenseInfo | null;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
onManageClick?: () => void;
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: { value: string; label: string }[];
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentLicenseInfo,
|
||||
onUpgradeClick,
|
||||
onManageClick,
|
||||
currency,
|
||||
onCurrencyChange,
|
||||
currencyOptions,
|
||||
loginEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
// Group plans by tier (Free, Server, Enterprise)
|
||||
const groupedPlans = useMemo(() => {
|
||||
return licenseService.groupPlansByTier(plans);
|
||||
}, [plans]);
|
||||
|
||||
// Calculate current tier from license info
|
||||
const currentTier = useMemo(() => {
|
||||
return mapLicenseToTier(currentLicenseInfo || null);
|
||||
}, [currentLicenseInfo]);
|
||||
|
||||
// Determine if the current tier matches (checks both Stripe subscription and license)
|
||||
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
||||
return checkIsCurrentTier(currentTier, tierGroup.tier);
|
||||
};
|
||||
|
||||
// Determine if selecting this plan would be a downgrade
|
||||
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
|
||||
return checkIsDowngrade(currentTier, tierGroup.tier);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group justify="space-between" align="flex-start" mb="xs">
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 0 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.availablePlans.subtitle",
|
||||
"Choose the plan that fits your needs",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{currency && onCurrencyChange && currencyOptions && (
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => onCurrencyChange(value || "usd")}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={false}
|
||||
w={300}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||
}}
|
||||
disabled={!loginEnabled}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "1rem",
|
||||
marginBottom: "0.1rem",
|
||||
}}
|
||||
>
|
||||
{groupedPlans.map((group) => (
|
||||
<PlanCard
|
||||
key={group.tier}
|
||||
planGroup={group}
|
||||
isCurrentTier={isCurrentTier(group)}
|
||||
isDowngrade={isDowngrade(group)}
|
||||
currentLicenseInfo={currentLicenseInfo}
|
||||
currentTier={currentTier}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
onManageClick={onManageClick}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t("plan.hideComparison", "Hide Feature Comparison")
|
||||
: t("plan.showComparison", "Compare All Features")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<FeatureComparisonTable
|
||||
plans={groupedPlans}
|
||||
currentTier={currentTier}
|
||||
/>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { Card, Badge, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanFeature } from "@app/services/licenseService";
|
||||
|
||||
interface PlanWithFeatures {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
popular?: boolean;
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
interface FeatureComparisonTableProps {
|
||||
plans: PlanWithFeatures[];
|
||||
currentTier?: "free" | "server" | "enterprise" | null;
|
||||
}
|
||||
|
||||
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({
|
||||
plans,
|
||||
currentTier,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder style={{ marginTop: "1rem" }}>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.featureComparison", "Feature Comparison")}
|
||||
</Text>
|
||||
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr
|
||||
style={{ borderBottom: "2px solid var(--mantine-color-gray-3)" }}
|
||||
>
|
||||
<th style={{ textAlign: "left", padding: "0.75rem" }}>
|
||||
{t("plan.feature.title", "Feature")}
|
||||
</th>
|
||||
{plans.map((plan, index) => (
|
||||
<th
|
||||
key={plan.tier || plan.name || index}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "0.75rem",
|
||||
minWidth: "8rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular &&
|
||||
!(
|
||||
plan.tier === "server" && currentTier === "enterprise"
|
||||
) && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.5rem",
|
||||
right: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0]?.features.map((_, featureIndex) => (
|
||||
<tr
|
||||
key={featureIndex}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0.75rem" }}>
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map((plan, planIndex) => (
|
||||
<td
|
||||
key={planIndex}
|
||||
style={{ textAlign: "center", padding: "0.75rem" }}
|
||||
>
|
||||
{plan.features[featureIndex]?.included ? (
|
||||
<Text c="green" fw={600} size="lg">
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray" size="sm">
|
||||
−
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureComparisonTable;
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Alert,
|
||||
TextInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
SegmentedControl,
|
||||
FileButton,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { LicenseInfo } from "@app/services/licenseService";
|
||||
import licenseService from "@app/services/licenseService";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
|
||||
interface LicenseKeySectionProps {
|
||||
currentLicenseInfo?: LicenseInfo;
|
||||
}
|
||||
|
||||
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({
|
||||
currentLicenseInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { refetchLicense } = useLicense();
|
||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState<string>("");
|
||||
const [savingLicense, setSavingLicense] = useState(false);
|
||||
const [inputMethod, setInputMethod] = useState<"text" | "file">("text");
|
||||
const [licenseFile, setLicenseFile] = useState<File | null>(null);
|
||||
|
||||
const handleSaveLicense = async () => {
|
||||
// Block save if login is disabled
|
||||
if (!validateLoginEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLicense(true);
|
||||
|
||||
let response;
|
||||
|
||||
if (inputMethod === "file" && licenseFile) {
|
||||
// Upload file
|
||||
response = await licenseService.saveLicenseFile(licenseFile);
|
||||
} else if (inputMethod === "text" && licenseKeyInput.trim()) {
|
||||
// Save key string
|
||||
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t(
|
||||
"admin.settings.premium.noInput",
|
||||
"Please provide a license key or file",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
// Refresh license context to update all components
|
||||
await refetchLicense();
|
||||
|
||||
const successMessage =
|
||||
inputMethod === "file"
|
||||
? t(
|
||||
"admin.settings.premium.file.successMessage",
|
||||
"License file uploaded and activated successfully",
|
||||
)
|
||||
: t(
|
||||
"admin.settings.premium.key.successMessage",
|
||||
"License key activated successfully",
|
||||
);
|
||||
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("success", "Success"),
|
||||
body: successMessage,
|
||||
});
|
||||
|
||||
// Clear inputs
|
||||
setLicenseKeyInput("");
|
||||
setLicenseFile(null);
|
||||
setInputMethod("text"); // Reset to default
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body:
|
||||
response.error ||
|
||||
t("admin.settings.saveError", "Failed to save license"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save license:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save license"),
|
||||
});
|
||||
} finally {
|
||||
setSavingLicense(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon={
|
||||
showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"
|
||||
}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
/>
|
||||
}
|
||||
onClick={() => setShowLicenseKey(!showLicenseKey)}
|
||||
>
|
||||
{t(
|
||||
"admin.settings.premium.licenseKey.toggle",
|
||||
"Got a license key or certificate file?",
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showLicenseKey} mt="md">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"admin.settings.premium.licenseKey.info",
|
||||
"If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Severe warning if license already exists */}
|
||||
{currentLicenseInfo?.licenseKey && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={
|
||||
<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
title={t(
|
||||
"admin.settings.premium.key.overwriteWarning.title",
|
||||
"⚠️ Warning: Existing License Detected",
|
||||
)}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
"admin.settings.premium.key.overwriteWarning.line1",
|
||||
"Overwriting your current license key cannot be undone.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"admin.settings.premium.key.overwriteWarning.line2",
|
||||
"Your previous license will be permanently lost unless you have backed it up elsewhere.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"admin.settings.premium.key.overwriteWarning.line3",
|
||||
"Important: Keep license keys private and secure. Never share them publicly.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Show current license source */}
|
||||
{currentLicenseInfo?.licenseKey && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="check-circle-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"admin.settings.premium.currentLicense.title",
|
||||
"Active License",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{currentLicenseInfo.licenseKey.startsWith("file:")
|
||||
? t(
|
||||
"admin.settings.premium.currentLicense.file",
|
||||
"Source: License file ({{path}})",
|
||||
{
|
||||
path: currentLicenseInfo.licenseKey.substring(5),
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"admin.settings.premium.currentLicense.key",
|
||||
"Source: License key",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{t(
|
||||
"admin.settings.premium.currentLicense.type",
|
||||
"Type: {{type}}",
|
||||
{
|
||||
type: currentLicenseInfo.licenseType,
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Input method selector */}
|
||||
<SegmentedControl
|
||||
value={inputMethod}
|
||||
onChange={(value) => {
|
||||
setInputMethod(value as "text" | "file");
|
||||
// Clear opposite input when switching
|
||||
if (value === "text") setLicenseFile(null);
|
||||
if (value === "file") setLicenseKeyInput("");
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
label: t(
|
||||
"admin.settings.premium.inputMethod.text",
|
||||
"License Key",
|
||||
),
|
||||
value: "text",
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
"admin.settings.premium.inputMethod.file",
|
||||
"Certificate File",
|
||||
),
|
||||
value: "file",
|
||||
},
|
||||
]}
|
||||
disabled={!loginEnabled || savingLicense}
|
||||
/>
|
||||
|
||||
{/* Input area */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
{inputMethod === "text" ? (
|
||||
/* Text input */
|
||||
<TextInput
|
||||
label={t("admin.settings.premium.key.label", "License Key")}
|
||||
description={t(
|
||||
"admin.settings.premium.key.description",
|
||||
"Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
|
||||
)}
|
||||
value={licenseKeyInput}
|
||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||
placeholder={
|
||||
currentLicenseInfo?.licenseKey ||
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
}
|
||||
type="password"
|
||||
disabled={!loginEnabled || savingLicense}
|
||||
/>
|
||||
) : (
|
||||
/* File upload */
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
{t(
|
||||
"admin.settings.premium.file.label",
|
||||
"License Certificate File",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
{t(
|
||||
"admin.settings.premium.file.description",
|
||||
"Upload your .lic or .cert license file",
|
||||
)}
|
||||
</Text>
|
||||
<FileButton
|
||||
onChange={setLicenseFile}
|
||||
accept=".lic,.cert"
|
||||
disabled={!loginEnabled || savingLicense}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
leftSection={
|
||||
<LocalIcon
|
||||
icon="upload-file-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
disabled={!loginEnabled || savingLicense}
|
||||
>
|
||||
{licenseFile
|
||||
? licenseFile.name
|
||||
: t(
|
||||
"admin.settings.premium.file.choose",
|
||||
"Choose License File",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
{licenseFile && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t(
|
||||
"admin.settings.premium.file.selected",
|
||||
"Selected: {{filename}} ({{size}})",
|
||||
{
|
||||
filename: licenseFile.name,
|
||||
size: (licenseFile.size / 1024).toFixed(2) + " KB",
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={handleSaveLicense}
|
||||
loading={savingLicense}
|
||||
size="sm"
|
||||
disabled={
|
||||
!loginEnabled ||
|
||||
(inputMethod === "text" && !licenseKeyInput.trim()) ||
|
||||
(inputMethod === "file" && !licenseFile)
|
||||
}
|
||||
>
|
||||
{t("admin.settings.save", "Save Changes")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseKeySection;
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Text, Stack, Divider, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTierGroup, LicenseInfo } from "@app/services/licenseService";
|
||||
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
|
||||
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
|
||||
import { calculateDisplayPricing } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
||||
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from "@app/utils/planTierUtils";
|
||||
|
||||
interface PlanCardProps {
|
||||
planGroup: PlanTierGroup;
|
||||
isCurrentTier: boolean;
|
||||
isDowngrade: boolean;
|
||||
currentLicenseInfo?: LicenseInfo | null;
|
||||
currentTier?: "free" | "server" | "enterprise" | null;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
onManageClick?: () => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
planGroup,
|
||||
isCurrentTier,
|
||||
isDowngrade,
|
||||
currentLicenseInfo,
|
||||
currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick,
|
||||
loginEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Render Free plan
|
||||
if (planGroup.tier === "free") {
|
||||
// Get currency from the free plan
|
||||
const freeCurrency = planGroup.monthly?.currency || "$";
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getBaseCardStyle(isCurrentTier)}
|
||||
className="plan-card"
|
||||
>
|
||||
{isCurrentTier && (
|
||||
<PricingBadge
|
||||
type="current"
|
||||
label={t("plan.current", "Current Plan")}
|
||||
/>
|
||||
)}
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="xl" fw={700} mb="xs">
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
|
||||
{t("plan.from", "From")}
|
||||
</Text>
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={0}
|
||||
currency={freeCurrency}
|
||||
period={t("plan.free.forever", "Forever free")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button variant="filled" disabled fullWidth className="plan-button">
|
||||
{isCurrentTier
|
||||
? t("plan.current", "Current Plan")
|
||||
: t("plan.free.included", "Included")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Render Server or Enterprise plans
|
||||
const { monthly, yearly } = planGroup;
|
||||
const isEnterprise = planGroup.tier === "enterprise";
|
||||
|
||||
// Block enterprise for free tier users (must have server first)
|
||||
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(
|
||||
currentTier,
|
||||
planGroup.tier,
|
||||
);
|
||||
|
||||
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
||||
const { displayPrice, displaySeatPrice, displayCurrency } =
|
||||
calculateDisplayPricing(monthly || undefined, yearly || undefined);
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getBaseCardStyle(isCurrentTier)}
|
||||
className="plan-card"
|
||||
>
|
||||
{isCurrentTier ? (
|
||||
<PricingBadge
|
||||
type="current"
|
||||
label={t("plan.current", "Current Plan")}
|
||||
/>
|
||||
) : planGroup.popular &&
|
||||
!(planGroup.tier === "server" && currentTier === "enterprise") ? (
|
||||
<PricingBadge type="popular" label={t("plan.popular", "Popular")} />
|
||||
) : null}
|
||||
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
{/* Tier Name */}
|
||||
<div>
|
||||
<Text size="xl" fw={700} mb="xs">
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("plan.from", "From")}
|
||||
</Text>
|
||||
|
||||
{/* Price */}
|
||||
{isEnterprise && displaySeatPrice !== undefined ? (
|
||||
<>
|
||||
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
|
||||
{displayCurrency}
|
||||
{displaySeatPrice.toFixed(2)}
|
||||
</Text>
|
||||
<Text span size="1.5rem" c="dimmed" mt="xs">
|
||||
{t("plan.perSeat", "/seat")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t("plan.perMonth", "/month")}{" "}
|
||||
{t("plan.withServer", "+ Server Plan")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={displayPrice}
|
||||
currency={displayCurrency}
|
||||
period={t("plan.perMonth", "/month")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Highlights */}
|
||||
<Stack gap="xs">
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Stack gap="xs">
|
||||
{/* Show seat count for enterprise plans when current */}
|
||||
{isEnterprise &&
|
||||
isCurrentTier &&
|
||||
currentLicenseInfo &&
|
||||
currentLicenseInfo.maxUsers > 0 && (
|
||||
<Text size="sm" c="green" fw={500} ta="center">
|
||||
{t("plan.licensedSeats", "Licensed: {{count}} seats", {
|
||||
count: currentLicenseInfo.maxUsers,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Single Upgrade Button */}
|
||||
<Tooltip
|
||||
label={t("plan.enterprise.requiresServer", "Requires Server plan")}
|
||||
disabled={!isEnterpriseBlockedForFree}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
isCurrentTier && onManageClick
|
||||
? onManageClick()
|
||||
: onUpgradeClick(planGroup)
|
||||
}
|
||||
disabled={
|
||||
!loginEnabled || isDowngrade || isEnterpriseBlockedForFree
|
||||
}
|
||||
className="plan-button"
|
||||
>
|
||||
{isCurrentTier
|
||||
? t("plan.manage", "Manage")
|
||||
: isDowngrade
|
||||
? t("plan.free.included", "Included")
|
||||
: isEnterpriseBlockedForFree
|
||||
? t("plan.enterprise.requiresServer", "Requires Server")
|
||||
: isEnterprise
|
||||
? t("plan.selectPlan", "Select Plan")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Stack,
|
||||
Paper,
|
||||
Grid,
|
||||
TextInput,
|
||||
Button,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
||||
import { validateEmail } from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
|
||||
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
import {
|
||||
STATIC_STRIPE_LINKS,
|
||||
buildStripeUrlWithEmail,
|
||||
} from "@app/constants/staticStripeLinks";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import licenseService from "@app/services/licenseService";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
|
||||
interface StaticCheckoutModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planName: "server" | "enterprise";
|
||||
isUpgrade?: boolean;
|
||||
}
|
||||
|
||||
type Stage = "email" | "period-selection" | "license-activation";
|
||||
|
||||
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planName,
|
||||
isUpgrade = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const { refetchLicense } = useLicense();
|
||||
|
||||
const [stage, setStage] = useState<Stage>("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
|
||||
|
||||
// License activation state
|
||||
const [licenseKey, setLicenseKey] = useState("");
|
||||
const [savingLicense, setSavingLicense] = useState(false);
|
||||
const [licenseActivated, setLicenseActivated] = useState(false);
|
||||
|
||||
const handleEmailSubmit = () => {
|
||||
const validation = validateEmail(email);
|
||||
if (validation.valid) {
|
||||
setEmailError("");
|
||||
setStageHistory([...stageHistory, "email"]);
|
||||
setStage("period-selection");
|
||||
} else {
|
||||
setEmailError(validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeriodSelect = (period: "monthly" | "yearly") => {
|
||||
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
|
||||
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
|
||||
|
||||
// Open Stripe checkout in new tab
|
||||
window.open(urlWithEmail, "_blank");
|
||||
|
||||
// Transition to license activation stage
|
||||
setStageHistory([...stageHistory, "period-selection"]);
|
||||
setStage("license-activation");
|
||||
};
|
||||
|
||||
const handleActivateLicense = async () => {
|
||||
if (!licenseKey.trim()) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t(
|
||||
"admin.settings.premium.noInput",
|
||||
"Please provide a license key",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLicense(true);
|
||||
const response = await licenseService.saveLicenseKey(licenseKey.trim());
|
||||
|
||||
if (response.success) {
|
||||
// Refresh license context to update all components
|
||||
await refetchLicense();
|
||||
|
||||
setLicenseActivated(true);
|
||||
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("success", "Success"),
|
||||
body: t(
|
||||
"admin.settings.premium.key.successMessage",
|
||||
"License key activated successfully",
|
||||
),
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body:
|
||||
response.error ||
|
||||
t("admin.settings.saveError", "Failed to save license"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save license:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save license"),
|
||||
});
|
||||
} finally {
|
||||
setSavingLicense(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (stageHistory.length > 0) {
|
||||
const newHistory = [...stageHistory];
|
||||
const previousStage = newHistory.pop();
|
||||
setStageHistory(newHistory);
|
||||
if (previousStage) {
|
||||
setStage(previousStage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state when closing
|
||||
setStage("email");
|
||||
setEmail("");
|
||||
setEmailError("");
|
||||
setStageHistory([]);
|
||||
setLicenseKey("");
|
||||
setSavingLicense(false);
|
||||
setLicenseActivated(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (stage === "email") {
|
||||
if (isUpgrade) {
|
||||
return t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
|
||||
}
|
||||
return planName === "server"
|
||||
? t("plan.static.getLicense", "Get Server License")
|
||||
: t("plan.static.upgradeToEnterprise", "Upgrade to Enterprise");
|
||||
}
|
||||
if (stage === "period-selection") {
|
||||
return t("plan.static.selectPeriod", "Select Billing Period");
|
||||
}
|
||||
if (stage === "license-activation") {
|
||||
return t("plan.static.activateLicense", "Activate Your License");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (stage) {
|
||||
case "email":
|
||||
return (
|
||||
<EmailStage
|
||||
emailInput={email}
|
||||
setEmailInput={setEmail}
|
||||
emailError={emailError}
|
||||
onSubmit={handleEmailSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
case "period-selection":
|
||||
return (
|
||||
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
|
||||
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
|
||||
{/* Monthly Option */}
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => handlePeriodSelect("monthly")}
|
||||
>
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{ height: "100%", minHeight: "120px" }}
|
||||
justify="space-between"
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{t("payment.monthly", "Monthly")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("plan.static.monthlyBilling", "Monthly Billing")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Yearly Option */}
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => handlePeriodSelect("yearly")}
|
||||
>
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{ height: "100%", minHeight: "120px" }}
|
||||
justify="space-between"
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{t("payment.yearly", "Yearly")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("plan.static.yearlyBilling", "Yearly Billing")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case "license-activation":
|
||||
return (
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{ padding: "2rem", maxWidth: "600px", margin: "0 auto" }}
|
||||
>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={
|
||||
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
"plan.static.licenseActivation.checkoutOpened",
|
||||
"Checkout Opened in New Tab",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"plan.static.licenseActivation.instructions",
|
||||
"Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{licenseActivated ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
icon={
|
||||
<LocalIcon
|
||||
icon="check-circle-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
}
|
||||
title={t(
|
||||
"plan.static.licenseActivation.success",
|
||||
"License Activated!",
|
||||
)}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"plan.static.licenseActivation.successMessage",
|
||||
"Your license has been successfully activated. You can now close this window.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t(
|
||||
"plan.static.licenseActivation.enterKey",
|
||||
"Enter your license key below to activate your plan:",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t("admin.settings.premium.key.label", "License Key")}
|
||||
description={t(
|
||||
"plan.static.licenseActivation.keyDescription",
|
||||
"Paste the license key from your email",
|
||||
)}
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
disabled={savingLicense}
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
disabled={savingLicense}
|
||||
>
|
||||
{t(
|
||||
"plan.static.licenseActivation.doLater",
|
||||
"I'll do this later",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleActivateLicense}
|
||||
loading={savingLicense}
|
||||
disabled={!licenseKey.trim()}
|
||||
>
|
||||
{t(
|
||||
"plan.static.licenseActivation.activate",
|
||||
"Activate License",
|
||||
)}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{licenseActivated && (
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleClose}>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = stageHistory.length > 0 && stage !== "license-activation";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canGoBack && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={handleGoBack}
|
||||
aria-label={t("common.back", "Back")}
|
||||
>
|
||||
<LocalIcon icon="arrow-back" width={20} height={20} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={600} size="lg">
|
||||
{getModalTitle()}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size={isMobile ? "100%" : 600}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
fullScreen={isMobile}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaticCheckoutModal;
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Stack,
|
||||
Button,
|
||||
Collapse,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
|
||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
|
||||
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
|
||||
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
|
||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||
import { STATIC_STRIPE_LINKS } from "@app/constants/staticStripeLinks";
|
||||
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
|
||||
import { getBaseCardStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
import {
|
||||
isCurrentTier as checkIsCurrentTier,
|
||||
isDowngrade as checkIsDowngrade,
|
||||
isEnterpriseBlockedForFree,
|
||||
} from "@app/utils/planTierUtils";
|
||||
|
||||
interface StaticPlanSectionProps {
|
||||
currentLicenseInfo?: LicenseInfo;
|
||||
}
|
||||
|
||||
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
|
||||
currentLicenseInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
// Static checkout modal state
|
||||
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<"server" | "enterprise">(
|
||||
"server",
|
||||
);
|
||||
const [isUpgrade, setIsUpgrade] = useState(false);
|
||||
|
||||
const handleOpenCheckout = (
|
||||
plan: "server" | "enterprise",
|
||||
upgrade: boolean,
|
||||
) => {
|
||||
// Prevent Free → Enterprise (must have Server first)
|
||||
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
|
||||
if (currentTier === "free" && plan === "enterprise") {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("plan.enterprise.requiresServer", "Server Plan Required"),
|
||||
body: t(
|
||||
"plan.enterprise.requiresServerMessage",
|
||||
"Please upgrade to the Server plan first before upgrading to Enterprise.",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPlan(plan);
|
||||
setIsUpgrade(upgrade);
|
||||
setCheckoutModalOpened(true);
|
||||
};
|
||||
|
||||
const handleManageBilling = () => {
|
||||
// Show warning about email verification
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t(
|
||||
"plan.static.billingPortal.title",
|
||||
"Email Verification Required",
|
||||
),
|
||||
body: t(
|
||||
"plan.static.billingPortal.message",
|
||||
"You will need to verify your email address in the Stripe billing portal. Check your email for a login link.",
|
||||
),
|
||||
});
|
||||
|
||||
window.open(STATIC_STRIPE_LINKS.billingPortal, "_blank");
|
||||
};
|
||||
|
||||
const staticPlans = [
|
||||
{
|
||||
id: "free",
|
||||
name: t("plan.free.name", "Free"),
|
||||
price: 0,
|
||||
currency: "£",
|
||||
period: "",
|
||||
highlights: PLAN_HIGHLIGHTS.FREE,
|
||||
features: PLAN_FEATURES.FREE,
|
||||
maxUsers: 5,
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
name: "Server",
|
||||
price: 0,
|
||||
currency: "",
|
||||
period: "",
|
||||
popular: false,
|
||||
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
|
||||
features: PLAN_FEATURES.SERVER,
|
||||
maxUsers: "Unlimited users",
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: t("plan.enterprise.name", "Enterprise"),
|
||||
price: 0,
|
||||
currency: "",
|
||||
period: "",
|
||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
|
||||
features: PLAN_FEATURES.ENTERPRISE,
|
||||
maxUsers: "Custom",
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentPlan = () => {
|
||||
const tier = mapLicenseToTier(currentLicenseInfo || null);
|
||||
if (tier === "enterprise") return staticPlans[2];
|
||||
if (tier === "server") return staticPlans[1];
|
||||
return staticPlans[0]; // free
|
||||
};
|
||||
|
||||
const currentPlan = getCurrentPlan();
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.static.contactToUpgrade",
|
||||
"Contact us to upgrade or customize your plan",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "1rem",
|
||||
paddingBottom: "0.1rem",
|
||||
}}
|
||||
>
|
||||
{staticPlans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={getBaseCardStyle(plan.id === currentPlan.id)}
|
||||
className="plan-card"
|
||||
>
|
||||
{plan.id === currentPlan.id && (
|
||||
<PricingBadge
|
||||
type="current"
|
||||
label={t("plan.current", "Current Plan")}
|
||||
/>
|
||||
)}
|
||||
{plan.popular && plan.id !== currentPlan.id && (
|
||||
<PricingBadge
|
||||
type="popular"
|
||||
label={t("plan.popular", "Popular")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: "100%" }}>
|
||||
<div>
|
||||
<Text size="xl" fw={700} style={{ fontSize: "2rem" }}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{typeof plan.maxUsers === "string"
|
||||
? plan.maxUsers
|
||||
: `${t("plan.static.upTo", "Up to")} ${plan.maxUsers} ${t("workspace.people.license.users", "users")}`}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Tier-based button logic */}
|
||||
{(() => {
|
||||
const currentTier = mapLicenseToTier(
|
||||
currentLicenseInfo || null,
|
||||
);
|
||||
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
|
||||
const isDowngradePlan = checkIsDowngrade(
|
||||
currentTier,
|
||||
plan.id,
|
||||
);
|
||||
|
||||
// Free Plan
|
||||
if (plan.id === "free") {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
disabled
|
||||
fullWidth
|
||||
className="plan-button"
|
||||
>
|
||||
{isCurrent
|
||||
? t("plan.current", "Current Plan")
|
||||
: t("plan.free.included", "Included")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Server Plan
|
||||
if (plan.id === "server") {
|
||||
if (currentTier === "free") {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onClick={() => handleOpenCheckout("server", false)}
|
||||
className="plan-button"
|
||||
>
|
||||
{t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onClick={handleManageBilling}
|
||||
className="plan-button"
|
||||
>
|
||||
{t("plan.manage", "Manage")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (isDowngradePlan) {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
disabled
|
||||
fullWidth
|
||||
className="plan-button"
|
||||
>
|
||||
{t("plan.free.included", "Included")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enterprise Plan
|
||||
if (plan.id === "enterprise") {
|
||||
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"plan.enterprise.requiresServer",
|
||||
"Requires Server plan",
|
||||
)}
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="filled"
|
||||
disabled
|
||||
fullWidth
|
||||
className="plan-button"
|
||||
>
|
||||
{t(
|
||||
"plan.enterprise.requiresServer",
|
||||
"Requires Server",
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (currentTier === "server") {
|
||||
// TODO: Re-enable checkout flow when account syncing is ready
|
||||
// return (
|
||||
// <Button
|
||||
// variant="filled"
|
||||
// fullWidth
|
||||
// onClick={() => handleOpenCheckout('enterprise', true)}
|
||||
// className="plan-button"
|
||||
// >
|
||||
// {t('plan.selectPlan', 'Select Plan')}
|
||||
// </Button>
|
||||
// );
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled
|
||||
className="plan-button"
|
||||
>
|
||||
{t("plan.contact", "Contact Us")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onClick={handleManageBilling}
|
||||
className="plan-button"
|
||||
>
|
||||
{t("plan.manage", "Manage")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Toggle */}
|
||||
<div style={{ textAlign: "center", marginTop: "1rem" }}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t("plan.hideComparison", "Hide Feature Comparison")
|
||||
: t("plan.showComparison", "Compare All Features")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Table */}
|
||||
<Collapse in={showComparison}>
|
||||
<FeatureComparisonTable plans={staticPlans} />
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* License Key Section */}
|
||||
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
|
||||
|
||||
{/* Static Checkout Modal */}
|
||||
<StaticCheckoutModal
|
||||
opened={checkoutModalOpened}
|
||||
onClose={() => setCheckoutModalOpened(false)}
|
||||
planName={selectedPlan}
|
||||
isUpgrade={isUpgrade}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaticPlanSection;
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Stack, Group, Box } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
const SimpleBarChart: React.FC<SimpleBarChartProps> = ({ data, maxValue }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t("usage.noData", "No data available")}
|
||||
</Text>
|
||||
) : (
|
||||
data.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
maw="60%"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{item.value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({((item.value / maxValue) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "0.5rem",
|
||||
backgroundColor: "var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${(item.value / maxValue) * 100}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "var(--mantine-color-blue-6)",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface UsageAnalyticsChartProps {
|
||||
data: { label: string; value: number }[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsChart: React.FC<UsageAnalyticsChartProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const safeMaxValue = Math.max(
|
||||
...data.map((d) => d.value).filter((value) => Number.isFinite(value)),
|
||||
1,
|
||||
);
|
||||
const safeData = data.map((item) => ({
|
||||
label: item.label,
|
||||
value: Number.isFinite(item.value) ? Math.max(0, item.value) : 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("usage.chart.title", "Endpoint Usage Chart")}
|
||||
</Text>
|
||||
<SimpleBarChart data={safeData} maxValue={safeMaxValue} />
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsChart;
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Stack,
|
||||
Table,
|
||||
TableThead,
|
||||
TableTbody,
|
||||
TableTr,
|
||||
TableTh,
|
||||
TableTd,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EndpointStatistic } from "@app/services/usageAnalyticsService";
|
||||
|
||||
interface UsageAnalyticsTableProps {
|
||||
data: EndpointStatistic[];
|
||||
}
|
||||
|
||||
const UsageAnalyticsTable: React.FC<UsageAnalyticsTableProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("usage.table.title", "Detailed Statistics")}
|
||||
</Text>
|
||||
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={
|
||||
{
|
||||
"--table-border-color": "var(--mantine-color-gray-3)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<TableThead>
|
||||
<TableTr style={{ backgroundColor: "var(--mantine-color-gray-0)" }}>
|
||||
<TableTh
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
w="5%"
|
||||
>
|
||||
#
|
||||
</TableTh>
|
||||
<TableTh
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
w="55%"
|
||||
>
|
||||
{t("usage.table.endpoint", "Endpoint")}
|
||||
</TableTh>
|
||||
<TableTh
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
w="20%"
|
||||
ta="right"
|
||||
>
|
||||
{t("usage.table.visits", "Visits")}
|
||||
</TableTh>
|
||||
<TableTh
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
fz="sm"
|
||||
w="20%"
|
||||
ta="right"
|
||||
>
|
||||
{t("usage.table.percentage", "Percentage")}
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{data.length === 0 ? (
|
||||
<TableTr>
|
||||
<TableTd colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t("usage.table.noData", "No data available")}
|
||||
</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
) : (
|
||||
data.map((stat, index) => (
|
||||
<TableTr key={index}>
|
||||
<TableTd>
|
||||
<Text size="sm" c="dimmed">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text size="sm" truncate>
|
||||
{stat.endpoint}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="right">
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.visits.toLocaleString()}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="right">
|
||||
<Text size="sm" c="dimmed">
|
||||
{stat.percentage.toFixed(2)}%
|
||||
</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageAnalyticsTable;
|
||||
@@ -0,0 +1,63 @@
|
||||
.text-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin-top: 0.375rem; /* 6px */
|
||||
margin-bottom: 0.5rem; /* 8px */
|
||||
}
|
||||
|
||||
.text-divider .text-divider__rule {
|
||||
height: 0.0625rem; /* 1px */
|
||||
flex: 1 1 0%;
|
||||
background-color: rgb(
|
||||
var(--text-divider-rule-rgb, var(--gray-200)) /
|
||||
var(--text-divider-opacity, 1)
|
||||
);
|
||||
}
|
||||
|
||||
.text-divider .text-divider__label {
|
||||
color: rgb(
|
||||
var(--text-divider-label-rgb, var(--gray-400)) /
|
||||
var(--text-divider-opacity, 1)
|
||||
);
|
||||
font-size: 0.75rem; /* 12px */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-divider.subcategory {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color);
|
||||
}
|
||||
|
||||
.text-divider.subcategory .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Force light theme colors regardless of dark mode */
|
||||
.text-divider.force-light .text-divider__rule {
|
||||
background-color: rgb(
|
||||
var(--text-divider-rule-rgb-light, var(--gray-200)) /
|
||||
var(--text-divider-opacity, 1)
|
||||
);
|
||||
}
|
||||
|
||||
.text-divider.force-light .text-divider__label {
|
||||
color: rgb(
|
||||
var(--text-divider-label-rgb-light, var(--gray-400)) /
|
||||
var(--text-divider-opacity, 1)
|
||||
);
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__rule {
|
||||
background-color: var(--tool-subcategory-rule-color-light);
|
||||
}
|
||||
|
||||
.text-divider.subcategory.force-light .text-divider__label {
|
||||
color: var(--tool-subcategory-text-color-light);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { getLogoFolder } from "@app/constants/logo";
|
||||
import type { LogoVariant } from "@app/services/preferencesService";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export type LoginCarouselSlide = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
cornerModelUrl?: string;
|
||||
followMouseTilt?: boolean;
|
||||
tiltMaxDeg?: number;
|
||||
};
|
||||
|
||||
export const buildLoginSlides = (
|
||||
variant: LogoVariant | null | undefined,
|
||||
t: TFunction,
|
||||
): LoginCarouselSlide[] => {
|
||||
const folder = getLogoFolder(variant);
|
||||
const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
|
||||
|
||||
return [
|
||||
{
|
||||
src: heroImage,
|
||||
alt: t("login.slides.overview.alt", "Stirling PDF overview"),
|
||||
title: t(
|
||||
"login.slides.overview.title",
|
||||
"Your one-stop-shop for all your PDF needs.",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.overview.subtitle",
|
||||
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/AddToPDF.png`,
|
||||
alt: t("login.slides.edit.alt", "Edit PDFs"),
|
||||
title: t(
|
||||
"login.slides.edit.title",
|
||||
"Edit PDFs to display/secure the information you want",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.edit.subtitle",
|
||||
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/SecurePDF.png`,
|
||||
alt: t("login.slides.secure.alt", "Secure PDFs"),
|
||||
title: t(
|
||||
"login.slides.secure.title",
|
||||
"Protect sensitive information in your PDFs",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.secure.subtitle",
|
||||
"Add passwords, redact content, and manage certificates with ease.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default buildLoginSlides;
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Modal, Text, Group, ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import licenseService from "@app/services/licenseService";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import { StripeCheckoutProps } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
import {
|
||||
validateEmail,
|
||||
getModalTitle,
|
||||
} from "@app/components/shared/stripeCheckout/utils/checkoutUtils";
|
||||
import { calculateSavings } from "@app/components/shared/stripeCheckout/utils/savingsCalculator";
|
||||
import { useCheckoutState } from "@app/components/shared/stripeCheckout/hooks/useCheckoutState";
|
||||
import { useCheckoutNavigation } from "@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation";
|
||||
import { useLicensePolling } from "@app/components/shared/stripeCheckout/hooks/useLicensePolling";
|
||||
import { useCheckoutSession } from "@app/components/shared/stripeCheckout/hooks/useCheckoutSession";
|
||||
import { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
|
||||
import { PlanSelectionStage } from "@app/components/shared/stripeCheckout/stages/PlanSelectionStage";
|
||||
import { PaymentStage } from "@app/components/shared/stripeCheckout/stages/PaymentStage";
|
||||
import { SuccessStage } from "@app/components/shared/stripeCheckout/stages/SuccessStage";
|
||||
import { ErrorStage } from "@app/components/shared/stripeCheckout/stages/ErrorStage";
|
||||
|
||||
// Validate Stripe key (static validation, no dynamic imports)
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!STRIPE_KEY) {
|
||||
console.error(
|
||||
"VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. " +
|
||||
"Please add it to your .env file. " +
|
||||
"Get your key from https://dashboard.stripe.com/apikeys",
|
||||
);
|
||||
}
|
||||
|
||||
if (STRIPE_KEY && !STRIPE_KEY.startsWith("pk_")) {
|
||||
console.error(
|
||||
`Invalid Stripe publishable key format. ` +
|
||||
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planGroup,
|
||||
minimumSeats = 1,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLicenseActivated,
|
||||
hostedCheckoutSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Initialize all state via custom hook
|
||||
const checkoutState = useCheckoutState(planGroup);
|
||||
|
||||
// Initialize navigation hooks
|
||||
const navigation = useCheckoutNavigation(
|
||||
checkoutState.state,
|
||||
checkoutState.setState,
|
||||
checkoutState.stageHistory,
|
||||
checkoutState.setStageHistory,
|
||||
);
|
||||
|
||||
// Initialize license polling hook
|
||||
const polling = useLicensePolling(
|
||||
checkoutState.isMountedRef,
|
||||
checkoutState.setPollingStatus,
|
||||
checkoutState.setLicenseKey,
|
||||
onLicenseActivated,
|
||||
);
|
||||
|
||||
// Initialize checkout session hook
|
||||
const session = useCheckoutSession(
|
||||
checkoutState.selectedPlan,
|
||||
checkoutState.state,
|
||||
checkoutState.setState,
|
||||
checkoutState.installationId,
|
||||
checkoutState.setInstallationId,
|
||||
checkoutState.currentLicenseKey,
|
||||
checkoutState.setCurrentLicenseKey,
|
||||
checkoutState.setPollingStatus,
|
||||
minimumSeats,
|
||||
polling.pollForLicenseKey,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLicenseActivated,
|
||||
);
|
||||
|
||||
// Calculate savings
|
||||
const savings = calculateSavings(planGroup, minimumSeats);
|
||||
|
||||
// Email submission handler
|
||||
const handleEmailSubmit = () => {
|
||||
const validation = validateEmail(checkoutState.emailInput);
|
||||
if (validation.valid) {
|
||||
checkoutState.setState((prev) => ({
|
||||
...prev,
|
||||
email: checkoutState.emailInput,
|
||||
}));
|
||||
navigation.goToStage("plan-selection");
|
||||
} else {
|
||||
checkoutState.setEmailError(validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Plan selection handler
|
||||
const handlePlanSelect = (period: "monthly" | "yearly") => {
|
||||
checkoutState.setSelectedPeriod(period);
|
||||
navigation.goToStage("payment");
|
||||
};
|
||||
|
||||
// Close handler
|
||||
const handleClose = () => {
|
||||
// Clear any active polling
|
||||
if (checkoutState.pollingTimeoutRef.current) {
|
||||
clearTimeout(checkoutState.pollingTimeoutRef.current);
|
||||
checkoutState.pollingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
checkoutState.resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
checkoutState.isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
checkoutState.isMountedRef.current = false;
|
||||
if (checkoutState.pollingTimeoutRef.current) {
|
||||
clearTimeout(checkoutState.pollingTimeoutRef.current);
|
||||
checkoutState.pollingTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [checkoutState.isMountedRef, checkoutState.pollingTimeoutRef]);
|
||||
|
||||
// Initialize stage based on existing license
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
// Handle hosted checkout success - open directly to success state
|
||||
if (hostedCheckoutSuccess) {
|
||||
console.log("Opening modal to success state for hosted checkout return");
|
||||
|
||||
// Set appropriate state based on upgrade vs new subscription
|
||||
if (hostedCheckoutSuccess.isUpgrade) {
|
||||
checkoutState.setCurrentLicenseKey("existing"); // Flag to indicate upgrade
|
||||
checkoutState.setPollingStatus("ready");
|
||||
} else if (hostedCheckoutSuccess.licenseKey) {
|
||||
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
|
||||
checkoutState.setPollingStatus("ready");
|
||||
}
|
||||
|
||||
// Set to success state to show success UI
|
||||
checkoutState.setState({ currentStage: "success", loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing license to skip email stage
|
||||
const checkExistingLicense = async () => {
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
// Only skip email if license is PRO or ENTERPRISE (not NORMAL/free tier)
|
||||
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
|
||||
// Has valid premium license - skip email stage
|
||||
console.log("Valid premium license detected - skipping email stage");
|
||||
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey || null);
|
||||
checkoutState.setState({
|
||||
currentStage: "plan-selection",
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
// No valid premium license - start at email stage
|
||||
checkoutState.setState({ currentStage: "email", loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Could not check for existing license:", error);
|
||||
// Default to email stage if check fails
|
||||
checkoutState.setState({ currentStage: "email", loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkExistingLicense();
|
||||
}, [
|
||||
opened,
|
||||
hostedCheckoutSuccess,
|
||||
checkoutState.setCurrentLicenseKey,
|
||||
checkoutState.setPollingStatus,
|
||||
checkoutState.setLicenseKey,
|
||||
checkoutState.setState,
|
||||
]);
|
||||
|
||||
// Trigger checkout session creation when entering payment stage
|
||||
useEffect(() => {
|
||||
if (
|
||||
checkoutState.state.currentStage === "payment" &&
|
||||
!checkoutState.state.clientSecret &&
|
||||
!checkoutState.state.loading
|
||||
) {
|
||||
session.createCheckoutSession();
|
||||
}
|
||||
}, [
|
||||
checkoutState.state.currentStage,
|
||||
checkoutState.state.clientSecret,
|
||||
checkoutState.state.loading,
|
||||
session,
|
||||
]);
|
||||
|
||||
// Render stage content
|
||||
const renderContent = () => {
|
||||
// Don't block checkout - hosted mode works without publishable key
|
||||
// The checkout will automatically redirect to Stripe hosted page if key is missing
|
||||
switch (checkoutState.state.currentStage) {
|
||||
case "email":
|
||||
return (
|
||||
<EmailStage
|
||||
emailInput={checkoutState.emailInput}
|
||||
setEmailInput={checkoutState.setEmailInput}
|
||||
emailError={checkoutState.emailError}
|
||||
onSubmit={handleEmailSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
case "plan-selection":
|
||||
return (
|
||||
<PlanSelectionStage
|
||||
planGroup={planGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
savings={savings}
|
||||
onSelectPlan={handlePlanSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
case "payment":
|
||||
return (
|
||||
<PaymentStage
|
||||
clientSecret={checkoutState.state.clientSecret || null}
|
||||
selectedPlan={checkoutState.selectedPlan}
|
||||
onPaymentComplete={session.handlePaymentComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
case "success":
|
||||
return (
|
||||
<SuccessStage
|
||||
pollingStatus={checkoutState.pollingStatus}
|
||||
currentLicenseKey={checkoutState.currentLicenseKey}
|
||||
licenseKey={checkoutState.licenseKey}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<ErrorStage
|
||||
error={checkoutState.state.error || "An unknown error occurred"}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = checkoutState.stageHistory.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canGoBack && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={navigation.goBack}
|
||||
aria-label={t("common.back", "Back")}
|
||||
>
|
||||
<LocalIcon icon="arrow-back" width={20} height={20} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={600} size="lg">
|
||||
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
fullScreen={isMobile}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {},
|
||||
content: {
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import { Text, Stack } from "@mantine/core";
|
||||
import { formatPrice } from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
||||
import { PRICE_FONT_WEIGHT } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
|
||||
interface SimplePriceProps {
|
||||
mode: "simple";
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface EnterprisePriceProps {
|
||||
mode: "enterprise";
|
||||
basePrice: number;
|
||||
seatPrice: number;
|
||||
totalPrice?: number;
|
||||
currency: string;
|
||||
period: "month" | "year";
|
||||
seatCount?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
|
||||
|
||||
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
|
||||
if (props.mode === "simple") {
|
||||
const fontSize = props.size || "2.25rem";
|
||||
return (
|
||||
<>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
|
||||
{formatPrice(props.price, props.currency)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{props.period}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Enterprise mode
|
||||
const {
|
||||
basePrice,
|
||||
seatPrice,
|
||||
totalPrice,
|
||||
currency,
|
||||
period,
|
||||
seatCount,
|
||||
size = "md",
|
||||
} = props;
|
||||
const fontSize = size === "lg" ? "2rem" : size === "sm" ? "md" : "xl";
|
||||
const totalFontSize = size === "lg" ? "2rem" : "2rem";
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Base Price
|
||||
</Text>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
|
||||
{formatPrice(basePrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{" "}
|
||||
/{period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Per Seat
|
||||
</Text>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
|
||||
{formatPrice(seatPrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{" "}
|
||||
/seat/{period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
{totalPrice !== undefined && seatCount && (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Total ({seatCount} seats)
|
||||
</Text>
|
||||
<Text
|
||||
size={totalFontSize}
|
||||
fw={PRICE_FONT_WEIGHT}
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
{formatPrice(totalPrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{" "}
|
||||
/{period === "year" ? "month" : period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
interface PricingBadgeProps {
|
||||
type: "current" | "popular" | "savings";
|
||||
label: string;
|
||||
savingsPercent?: number;
|
||||
}
|
||||
|
||||
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
|
||||
const color = type === "current" || type === "savings" ? "green" : "blue";
|
||||
const size = type === "savings" ? "lg" : "sm";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="filled"
|
||||
size={size}
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
className={type === "current" ? "current-plan-badge" : undefined}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
CheckoutState,
|
||||
CheckoutStage,
|
||||
} from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* Stage navigation and history management hook
|
||||
*/
|
||||
export const useCheckoutNavigation = (
|
||||
state: CheckoutState,
|
||||
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||
stageHistory: CheckoutStage[],
|
||||
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>,
|
||||
) => {
|
||||
const goToStage = useCallback(
|
||||
(nextStage: CheckoutStage) => {
|
||||
setStageHistory((prev) => [...prev, state.currentStage]);
|
||||
setState((prev) => ({ ...prev, currentStage: nextStage }));
|
||||
},
|
||||
[state.currentStage, setState, setStageHistory],
|
||||
);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (stageHistory.length > 0) {
|
||||
const previousStage = stageHistory[stageHistory.length - 1];
|
||||
setStageHistory((prev) => prev.slice(0, -1));
|
||||
|
||||
// Reset payment state when going back from payment stage
|
||||
if (state.currentStage === "payment") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentStage: previousStage,
|
||||
clientSecret: undefined,
|
||||
loading: false,
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, currentStage: previousStage }));
|
||||
}
|
||||
}
|
||||
}, [stageHistory, state.currentStage, setState, setStageHistory]);
|
||||
|
||||
return { goToStage, goBack };
|
||||
};
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { useCallback } from "react";
|
||||
import licenseService, { PlanTier } from "@app/services/licenseService";
|
||||
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
|
||||
import {
|
||||
CheckoutState,
|
||||
PollingStatus,
|
||||
} from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* Checkout session creation and payment handling hook
|
||||
*/
|
||||
export const useCheckoutSession = (
|
||||
selectedPlan: PlanTier | null,
|
||||
state: CheckoutState,
|
||||
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||
installationId: string | null,
|
||||
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
currentLicenseKey: string | null,
|
||||
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||
minimumSeats: number,
|
||||
pollForLicenseKey: (installId: string) => Promise<void>,
|
||||
onSuccess?: (sessionId: string) => void,
|
||||
onError?: (error: string) => void,
|
||||
onLicenseActivated?: (licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => void,
|
||||
) => {
|
||||
const createCheckoutSession = useCallback(async () => {
|
||||
if (!selectedPlan) {
|
||||
setState({
|
||||
currentStage: "error",
|
||||
error: "Selected plan period is not available",
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
|
||||
// Fetch installation ID from backend
|
||||
let fetchedInstallationId = installationId;
|
||||
if (!fetchedInstallationId) {
|
||||
fetchedInstallationId = await licenseService.getInstallationId();
|
||||
setInstallationId(fetchedInstallationId);
|
||||
}
|
||||
|
||||
// Fetch current license key for upgrades
|
||||
// Only include if it's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
|
||||
let existingLicenseKey: string | undefined;
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
if (
|
||||
licenseInfo?.licenseType &&
|
||||
licenseInfo.licenseType !== "NORMAL" &&
|
||||
licenseInfo.licenseKey
|
||||
) {
|
||||
existingLicenseKey = licenseInfo.licenseKey;
|
||||
setCurrentLicenseKey(existingLicenseKey);
|
||||
console.log("Found existing valid license for upgrade");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Could not fetch license info, proceeding as new license:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await licenseService.createCheckoutSession({
|
||||
lookup_key: selectedPlan.lookupKey,
|
||||
installation_id: fetchedInstallationId,
|
||||
current_license_key: existingLicenseKey,
|
||||
requires_seats: selectedPlan.requiresSeats,
|
||||
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
|
||||
email: state.email, // Pass collected email from Stage 1
|
||||
});
|
||||
|
||||
// Check if we got a redirect URL (hosted checkout for HTTP)
|
||||
if (response.url) {
|
||||
console.log("Redirecting to Stripe hosted checkout:", response.url);
|
||||
// Redirect to Stripe's hosted checkout page
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use embedded checkout (HTTPS)
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
clientSecret: response.clientSecret,
|
||||
sessionId: response.sessionId,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to create checkout session";
|
||||
setState({
|
||||
currentStage: "error",
|
||||
error: errorMessage,
|
||||
loading: false,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}, [
|
||||
selectedPlan,
|
||||
state.email,
|
||||
installationId,
|
||||
minimumSeats,
|
||||
setState,
|
||||
setInstallationId,
|
||||
setCurrentLicenseKey,
|
||||
onError,
|
||||
]);
|
||||
|
||||
const handlePaymentComplete = useCallback(async () => {
|
||||
// Preserve state when changing stage
|
||||
setState((prev) => ({ ...prev, currentStage: "success" }));
|
||||
|
||||
// Check if this is an upgrade (existing license key) or new plan
|
||||
if (currentLicenseKey) {
|
||||
// UPGRADE FLOW: Resync existing license with Keygen
|
||||
console.log("Upgrade detected - resyncing existing license with Keygen");
|
||||
setPollingStatus("polling");
|
||||
|
||||
const activation = await resyncExistingLicense({
|
||||
isMounted: () => true, // Modal is open, no need to check
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (activation.success) {
|
||||
console.log(`License upgraded successfully: ${activation.licenseType}`);
|
||||
setPollingStatus("ready");
|
||||
} else {
|
||||
console.error("Failed to sync upgraded license:", activation.error);
|
||||
setPollingStatus("timeout");
|
||||
}
|
||||
|
||||
// Notify parent (don't wait - upgrade is complete)
|
||||
onSuccess?.(state.sessionId || "");
|
||||
} else {
|
||||
// NEW PLAN FLOW: Poll for new license key
|
||||
console.log("New subscription - polling for license key");
|
||||
|
||||
if (installationId) {
|
||||
pollForLicenseKey(installationId).finally(() => {
|
||||
// Only notify parent after polling completes or times out
|
||||
onSuccess?.(state.sessionId || "");
|
||||
});
|
||||
} else {
|
||||
// No installation ID, notify immediately
|
||||
onSuccess?.(state.sessionId || "");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentLicenseKey,
|
||||
installationId,
|
||||
state.sessionId,
|
||||
setState,
|
||||
setPollingStatus,
|
||||
pollForLicenseKey,
|
||||
onSuccess,
|
||||
onLicenseActivated,
|
||||
]);
|
||||
|
||||
return {
|
||||
createCheckoutSession,
|
||||
handlePaymentComplete,
|
||||
};
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PlanTierGroup } from "@app/services/licenseService";
|
||||
import {
|
||||
CheckoutState,
|
||||
PollingStatus,
|
||||
CheckoutStage,
|
||||
} from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* Centralized state management hook for checkout flow
|
||||
*/
|
||||
export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||
const [state, setState] = useState<CheckoutState>({
|
||||
currentStage: "email",
|
||||
loading: false,
|
||||
});
|
||||
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
|
||||
const [emailInput, setEmailInput] = useState<string>("");
|
||||
const [emailError, setEmailError] = useState<string>("");
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<"monthly" | "yearly">(
|
||||
planGroup.yearly ? "yearly" : "monthly",
|
||||
);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [licenseKey, setLicenseKey] = useState<string | null>(null);
|
||||
const [pollingStatus, setPollingStatus] = useState<PollingStatus>("idle");
|
||||
|
||||
// Refs for polling cleanup
|
||||
const isMountedRef = useRef(true);
|
||||
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Get the selected plan based on period
|
||||
const selectedPlan =
|
||||
selectedPeriod === "yearly" ? planGroup.yearly : planGroup.monthly;
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState({
|
||||
currentStage: "email",
|
||||
loading: false,
|
||||
clientSecret: undefined,
|
||||
sessionId: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
setStageHistory([]);
|
||||
setEmailInput("");
|
||||
setEmailError("");
|
||||
setPollingStatus("idle");
|
||||
setCurrentLicenseKey(null);
|
||||
setLicenseKey(null);
|
||||
setSelectedPeriod(planGroup.yearly ? "yearly" : "monthly");
|
||||
}, [planGroup]);
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
setState,
|
||||
stageHistory,
|
||||
setStageHistory,
|
||||
emailInput,
|
||||
setEmailInput,
|
||||
emailError,
|
||||
setEmailError,
|
||||
selectedPeriod,
|
||||
setSelectedPeriod,
|
||||
installationId,
|
||||
setInstallationId,
|
||||
currentLicenseKey,
|
||||
setCurrentLicenseKey,
|
||||
licenseKey,
|
||||
setLicenseKey,
|
||||
pollingStatus,
|
||||
setPollingStatus,
|
||||
// Refs
|
||||
isMountedRef,
|
||||
pollingTimeoutRef,
|
||||
// Computed
|
||||
selectedPlan,
|
||||
// Actions
|
||||
resetState,
|
||||
};
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
pollLicenseKeyWithBackoff,
|
||||
activateLicenseKey,
|
||||
} from "@app/utils/licenseCheckoutUtils";
|
||||
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* License key polling and activation logic hook
|
||||
*/
|
||||
export const useLicensePolling = (
|
||||
isMountedRef: React.RefObject<boolean>,
|
||||
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
onLicenseActivated?: (licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => void,
|
||||
) => {
|
||||
const pollForLicenseKey = useCallback(
|
||||
async (installId: string) => {
|
||||
// Use shared polling utility
|
||||
const result = await pollLicenseKeyWithBackoff(installId, {
|
||||
isMounted: () => isMountedRef.current ?? false,
|
||||
onStatusChange: setPollingStatus,
|
||||
});
|
||||
|
||||
if (result.success && result.licenseKey) {
|
||||
setLicenseKey(result.licenseKey);
|
||||
|
||||
// Activate the license key
|
||||
const activation = await activateLicenseKey(result.licenseKey, {
|
||||
isMounted: () => isMountedRef.current ?? false,
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (!activation.success) {
|
||||
console.error("Failed to activate license key:", activation.error);
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
console.warn("License key polling timed out");
|
||||
} else if (result.error) {
|
||||
console.error("License key polling failed:", result.error);
|
||||
}
|
||||
},
|
||||
[isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated],
|
||||
);
|
||||
|
||||
return { pollForLicenseKey };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as StripeCheckout } from "@app/components/shared/stripeCheckout/StripeCheckout";
|
||||
export type {
|
||||
StripeCheckoutProps,
|
||||
CheckoutStage,
|
||||
CheckoutState,
|
||||
PollingStatus,
|
||||
SavingsCalculation,
|
||||
} from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, TextInput, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface EmailStageProps {
|
||||
emailInput: string;
|
||||
setEmailInput: (email: string) => void;
|
||||
emailError: string;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const EmailStage: React.FC<EmailStageProps> = ({
|
||||
emailInput,
|
||||
setEmailInput,
|
||||
emailError,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap="lg"
|
||||
style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem 0" }}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payment.emailStage.description",
|
||||
"We'll use this to send your license key and receipts.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t("payment.emailStage.emailLabel", "Email Address")}
|
||||
placeholder={t("payment.emailStage.emailPlaceholder", "[email protected]")}
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.currentTarget.value)}
|
||||
error={emailError}
|
||||
size="lg"
|
||||
required
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button size="lg" onClick={onSubmit} disabled={!emailInput.trim()}>
|
||||
{t("payment.emailStage.continue", "Continue")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { Alert, Stack, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ErrorStageProps {
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert color="red" title={t("payment.error", "Payment Error")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{error}</Text>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
EmbeddedCheckoutProvider,
|
||||
EmbeddedCheckout,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { PlanTier } from "@app/services/licenseService";
|
||||
|
||||
// Load Stripe once
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface PaymentStageProps {
|
||||
clientSecret: string | null;
|
||||
selectedPlan: PlanTier | null;
|
||||
onPaymentComplete: () => void;
|
||||
}
|
||||
|
||||
export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||
clientSecret,
|
||||
selectedPlan,
|
||||
onPaymentComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Show loading while creating checkout session
|
||||
if (!clientSecret || !selectedPlan) {
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ padding: "2rem 0" }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t("payment.preparing", "Preparing your checkout...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
// This should only happen if embedded mode was attempted without key
|
||||
// Hosted checkout should have redirected before reaching this component
|
||||
return (
|
||||
<Stack align="center" gap="md" style={{ padding: "2rem 0" }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t("payment.redirecting", "Redirecting to secure checkout...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Stripe Embedded Checkout */}
|
||||
<EmbeddedCheckoutProvider
|
||||
key={clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: clientSecret,
|
||||
onComplete: onPaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Grid,
|
||||
Paper,
|
||||
Alert,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTierGroup } from "@app/services/licenseService";
|
||||
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge";
|
||||
import { PriceDisplay } from "@app/components/shared/stripeCheckout/components/PriceDisplay";
|
||||
import {
|
||||
formatPrice,
|
||||
calculateMonthlyEquivalent,
|
||||
calculateTotalWithSeats,
|
||||
} from "@app/components/shared/stripeCheckout/utils/pricingUtils";
|
||||
import { getClickablePaperStyle } from "@app/components/shared/stripeCheckout/utils/cardStyles";
|
||||
|
||||
interface PlanSelectionStageProps {
|
||||
planGroup: PlanTierGroup;
|
||||
minimumSeats: number;
|
||||
savings: SavingsCalculation | null;
|
||||
onSelectPlan: (period: "monthly" | "yearly") => void;
|
||||
}
|
||||
|
||||
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
|
||||
planGroup,
|
||||
minimumSeats,
|
||||
savings,
|
||||
onSelectPlan,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isEnterprise = planGroup.tier === "enterprise";
|
||||
const seatCount = minimumSeats || 1;
|
||||
|
||||
return (
|
||||
<Stack gap="lg" style={{ padding: "1rem 2rem" }}>
|
||||
<Grid gutter="xl" style={{ marginTop: "1rem" }}>
|
||||
{/* Monthly Option */}
|
||||
{planGroup.monthly && (
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => onSelectPlan("monthly")}
|
||||
>
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{ height: "100%" }}
|
||||
justify="space-between"
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{t("payment.monthly", "Monthly")}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isEnterprise && planGroup.monthly.seatPrice ? (
|
||||
<PriceDisplay
|
||||
mode="enterprise"
|
||||
basePrice={planGroup.monthly.price}
|
||||
seatPrice={planGroup.monthly.seatPrice}
|
||||
totalPrice={calculateTotalWithSeats(
|
||||
planGroup.monthly.price,
|
||||
planGroup.monthly.seatPrice,
|
||||
seatCount,
|
||||
)}
|
||||
currency={planGroup.monthly.currency}
|
||||
period="month"
|
||||
seatCount={seatCount}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={planGroup.monthly?.price || 0}
|
||||
currency={planGroup.monthly?.currency || "£"}
|
||||
period={t("payment.perMonth", "/month")}
|
||||
size="2.5rem"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
|
||||
<Button variant="light" fullWidth size="lg">
|
||||
{t("payment.planStage.selectMonthly", "Select Monthly")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{/* Yearly Option */}
|
||||
{planGroup.yearly && (
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle(!!savings)}
|
||||
onClick={() => onSelectPlan("yearly")}
|
||||
>
|
||||
{savings && (
|
||||
<PricingBadge
|
||||
type="savings"
|
||||
label={t(
|
||||
"payment.planStage.savePercent",
|
||||
"Save {{percent}}%",
|
||||
{ percent: savings.percent },
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
gap="md"
|
||||
style={{ height: "100%" }}
|
||||
justify="space-between"
|
||||
>
|
||||
<Text size="lg" fw={600}>
|
||||
{t("payment.yearly", "Yearly")}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isEnterprise && planGroup.yearly.seatPrice ? (
|
||||
<Stack gap="sm">
|
||||
<PriceDisplay
|
||||
mode="enterprise"
|
||||
basePrice={planGroup.yearly.price}
|
||||
seatPrice={planGroup.yearly.seatPrice}
|
||||
totalPrice={calculateMonthlyEquivalent(
|
||||
calculateTotalWithSeats(
|
||||
planGroup.yearly.price,
|
||||
planGroup.yearly.seatPrice,
|
||||
seatCount,
|
||||
),
|
||||
)}
|
||||
currency={planGroup.yearly.currency}
|
||||
period="year"
|
||||
seatCount={seatCount}
|
||||
size="sm"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payment.planStage.billedYearly",
|
||||
"Billed yearly at {{currency}}{{amount}}",
|
||||
{
|
||||
currency: planGroup.yearly.currency,
|
||||
amount: calculateTotalWithSeats(
|
||||
planGroup.yearly.price,
|
||||
planGroup.yearly.seatPrice,
|
||||
seatCount,
|
||||
).toFixed(2),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={calculateMonthlyEquivalent(
|
||||
planGroup.yearly?.price || 0,
|
||||
)}
|
||||
currency={planGroup.yearly?.currency || "£"}
|
||||
period={t("payment.perMonth", "/month")}
|
||||
size="2.5rem"
|
||||
/>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t(
|
||||
"payment.planStage.billedYearly",
|
||||
"Billed yearly at {{currency}}{{amount}}",
|
||||
{
|
||||
currency: planGroup.yearly?.currency,
|
||||
amount: planGroup.yearly?.price.toFixed(2),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{savings && (
|
||||
<Alert color="green" variant="light" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t(
|
||||
"payment.planStage.savingsAmount",
|
||||
"You save {{amount}}",
|
||||
{
|
||||
amount: formatPrice(savings.amount, savings.currency),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "auto", paddingTop: "1rem" }}>
|
||||
<Button variant="filled" fullWidth size="lg">
|
||||
{t("payment.planStage.selectYearly", "Select Yearly")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Stack,
|
||||
Text,
|
||||
Paper,
|
||||
Code,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
interface SuccessStageProps {
|
||||
pollingStatus: PollingStatus;
|
||||
currentLicenseKey: string | null;
|
||||
licenseKey: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SuccessStage: React.FC<SuccessStageProps> = ({
|
||||
pollingStatus,
|
||||
currentLicenseKey,
|
||||
licenseKey,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert color="green" title={t("payment.success", "Payment Successful!")}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"payment.successMessage",
|
||||
"Your subscription has been activated successfully.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* License Key Polling Status */}
|
||||
{pollingStatus === "polling" && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentLicenseKey
|
||||
? t(
|
||||
"payment.syncingLicense",
|
||||
"Syncing your upgraded license...",
|
||||
)
|
||||
: t(
|
||||
"payment.generatingLicense",
|
||||
"Generating your license key...",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pollingStatus === "ready" && !currentLicenseKey && licenseKey && (
|
||||
<Paper withBorder p="md" radius="md" bg="gray.1">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("payment.licenseKey", "Your License Key")}
|
||||
</Text>
|
||||
<Code block>{licenseKey}</Code>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(licenseKey)}
|
||||
>
|
||||
{t("common.copy", "Copy to Clipboard")}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"payment.licenseInstructions",
|
||||
"This has been added to your installation. You will receive a copy in your email as well.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{pollingStatus === "ready" && currentLicenseKey && (
|
||||
<Alert
|
||||
color="green"
|
||||
title={t("payment.upgradeComplete", "Upgrade Complete")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"payment.upgradeCompleteMessage",
|
||||
"Your subscription has been upgraded successfully. Your existing license key has been updated.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === "timeout" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
title={t("payment.licenseDelayed", "License Key Processing")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"payment.licenseDelayedMessage",
|
||||
"Your license key is being generated. Please check your email shortly or contact support.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === "ready" && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("payment.canCloseWindow", "You can now close this window.")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} mt="md">
|
||||
{t("common.close", "Close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { PlanTierGroup } from "@app/services/licenseService";
|
||||
|
||||
export interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planGroup: PlanTierGroup;
|
||||
minimumSeats?: number;
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onLicenseActivated?: (licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type CheckoutStage =
|
||||
| "email"
|
||||
| "plan-selection"
|
||||
| "payment"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export type CheckoutState = {
|
||||
currentStage: CheckoutStage;
|
||||
email?: string;
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type PollingStatus = "idle" | "polling" | "ready" | "timeout";
|
||||
|
||||
export interface SavingsCalculation {
|
||||
amount: number;
|
||||
percent: number;
|
||||
currency: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
/**
|
||||
* Shared styling utilities for plan cards
|
||||
*/
|
||||
|
||||
export const CARD_MIN_HEIGHT = "400px";
|
||||
export const PRICE_FONT_WEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Get card border style based on state
|
||||
*/
|
||||
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
|
||||
return {
|
||||
borderColor: isHighlighted ? "var(--mantine-color-green-6)" : undefined,
|
||||
borderWidth: isHighlighted ? "2px" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base card style
|
||||
*/
|
||||
export function getBaseCardStyle(
|
||||
isHighlighted: boolean = false,
|
||||
): CSSProperties {
|
||||
return {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: CARD_MIN_HEIGHT,
|
||||
...getCardBorderStyle(isHighlighted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clickable paper style
|
||||
*/
|
||||
export function getClickablePaperStyle(
|
||||
isHighlighted: boolean = false,
|
||||
): CSSProperties {
|
||||
return {
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
...getCardBorderStyle(isHighlighted),
|
||||
};
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { CheckoutStage } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* Validate email address format
|
||||
*/
|
||||
export const validateEmail = (
|
||||
email: string,
|
||||
): { valid: boolean; error: string } => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Please enter a valid email address",
|
||||
};
|
||||
}
|
||||
return { valid: true, error: "" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get dynamic modal title based on current stage
|
||||
*/
|
||||
export const getModalTitle = (
|
||||
stage: CheckoutStage,
|
||||
planName: string,
|
||||
t: TFunction,
|
||||
): string => {
|
||||
switch (stage) {
|
||||
case "email":
|
||||
return t("payment.emailStage.modalTitle", "Get Started - {{planName}}", {
|
||||
planName,
|
||||
});
|
||||
case "plan-selection":
|
||||
return t(
|
||||
"payment.planStage.modalTitle",
|
||||
"Select Billing Period - {{planName}}",
|
||||
{ planName },
|
||||
);
|
||||
case "payment":
|
||||
return t(
|
||||
"payment.paymentStage.modalTitle",
|
||||
"Complete Payment - {{planName}}",
|
||||
{ planName },
|
||||
);
|
||||
case "success":
|
||||
return t("payment.success", "Payment Successful!");
|
||||
case "error":
|
||||
return t("payment.error", "Payment Error");
|
||||
default:
|
||||
return t("payment.upgradeTitle", "Upgrade to {{planName}}", { planName });
|
||||
}
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared pricing utilities for plan cards and checkout
|
||||
*/
|
||||
|
||||
export interface PriceCalculation {
|
||||
displayPrice: number;
|
||||
displaySeatPrice?: number;
|
||||
displayCurrency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate monthly equivalent from yearly price
|
||||
*/
|
||||
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
|
||||
return yearlyPrice / 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total price including seats
|
||||
*/
|
||||
export function calculateTotalWithSeats(
|
||||
basePrice: number,
|
||||
seatPrice: number | undefined,
|
||||
seatCount: number,
|
||||
): number {
|
||||
if (seatPrice === undefined) return basePrice;
|
||||
return basePrice + seatPrice * seatCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price with currency symbol
|
||||
*/
|
||||
export function formatPrice(
|
||||
amount: number,
|
||||
currency: string,
|
||||
decimals: number = 2,
|
||||
): string {
|
||||
return `${currency}${amount.toFixed(decimals)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate display pricing for a plan, showing yearly price divided by 12
|
||||
* to show the lowest monthly equivalent
|
||||
*/
|
||||
export function calculateDisplayPricing(
|
||||
monthly?: { price: number; seatPrice?: number; currency: string },
|
||||
yearly?: { price: number; seatPrice?: number; currency: string },
|
||||
): PriceCalculation {
|
||||
// Default to monthly if no yearly exists
|
||||
if (!yearly) {
|
||||
return {
|
||||
displayPrice: monthly?.price || 0,
|
||||
displaySeatPrice: monthly?.seatPrice,
|
||||
displayCurrency: monthly?.currency || "£",
|
||||
};
|
||||
}
|
||||
|
||||
// Use yearly price divided by 12 for best value display
|
||||
return {
|
||||
displayPrice: calculateMonthlyEquivalent(yearly.price),
|
||||
displaySeatPrice: yearly.seatPrice
|
||||
? calculateMonthlyEquivalent(yearly.seatPrice)
|
||||
: undefined,
|
||||
displayCurrency: yearly.currency,
|
||||
};
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { PlanTierGroup } from "@app/services/licenseService";
|
||||
import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout";
|
||||
|
||||
/**
|
||||
* Calculate savings for yearly vs monthly plans
|
||||
* Returns null if both monthly and yearly plans are not available
|
||||
*/
|
||||
export const calculateSavings = (
|
||||
planGroup: PlanTierGroup,
|
||||
minimumSeats: number,
|
||||
): SavingsCalculation | null => {
|
||||
if (!planGroup.yearly || !planGroup.monthly) return null;
|
||||
|
||||
const isEnterprise = planGroup.tier === "enterprise";
|
||||
const seatCount = minimumSeats || 1;
|
||||
|
||||
let monthlyAnnual: number;
|
||||
let yearlyTotal: number;
|
||||
|
||||
if (
|
||||
isEnterprise &&
|
||||
planGroup.monthly.seatPrice &&
|
||||
planGroup.yearly.seatPrice
|
||||
) {
|
||||
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
|
||||
monthlyAnnual =
|
||||
(planGroup.monthly.price + planGroup.monthly.seatPrice * seatCount) * 12;
|
||||
yearlyTotal =
|
||||
planGroup.yearly.price + planGroup.yearly.seatPrice * seatCount;
|
||||
} else {
|
||||
// Server: price * 12 vs yearly price
|
||||
monthlyAnnual = planGroup.monthly.price * 12;
|
||||
yearlyTotal = planGroup.yearly.price;
|
||||
}
|
||||
|
||||
const savings = monthlyAnnual - yearlyTotal;
|
||||
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
|
||||
|
||||
return {
|
||||
amount: savings,
|
||||
percent: savingsPercent,
|
||||
currency: planGroup.yearly.currency,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,459 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Card,
|
||||
Text,
|
||||
Badge,
|
||||
Group,
|
||||
Button,
|
||||
Loader,
|
||||
Alert,
|
||||
TextInput,
|
||||
FileInput,
|
||||
Select,
|
||||
} from "@mantine/core";
|
||||
import { useParticipantSession } from "@app/hooks/workflow/useParticipantSession";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
|
||||
interface ParticipantViewProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
session,
|
||||
participant,
|
||||
loading,
|
||||
error,
|
||||
submitSignature,
|
||||
decline,
|
||||
downloadDocument,
|
||||
} = useParticipantSession(token);
|
||||
|
||||
const [certType, setCertType] = useState<string>("P12");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [location, setLocation] = useState<string>("");
|
||||
const [reason, setReason] = useState<string>("Document Signing");
|
||||
const [showSignature, _setShowSignature] = useState<boolean>(true);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
const [declineReason, _setDeclineReason] = useState<string>("");
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [notification, setNotification] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
type CertValidationState =
|
||||
| { status: "idle" }
|
||||
| { status: "validating" }
|
||||
| { status: "valid"; notAfter: string | null; subjectName: string | null }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
const [certValidation, setCertValidation] = useState<CertValidationState>({
|
||||
status: "idle",
|
||||
});
|
||||
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (certType === "SERVER" || !certFile) {
|
||||
setCertValidation({ status: "idle" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||
setCertValidation({ status: "validating" });
|
||||
|
||||
validationTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("participantToken", token);
|
||||
formData.append("certType", certType);
|
||||
formData.append("password", password);
|
||||
if (certType === "JKS") {
|
||||
formData.append("jksFile", certFile);
|
||||
} else {
|
||||
formData.append("p12File", certFile);
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
"/api/v1/workflow/participant/validate-certificate",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.valid) {
|
||||
setCertValidation({
|
||||
status: "valid",
|
||||
notAfter: data.notAfter,
|
||||
subjectName: data.subjectName,
|
||||
});
|
||||
} else {
|
||||
setCertValidation({
|
||||
status: "error",
|
||||
message:
|
||||
data.error ??
|
||||
t(
|
||||
"certSign.collab.participant.certInvalidFallback",
|
||||
"Invalid certificate",
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setCertValidation({
|
||||
status: "error",
|
||||
message: t(
|
||||
"certSign.collab.participant.certNetworkError",
|
||||
"Could not validate certificate",
|
||||
),
|
||||
});
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||
};
|
||||
}, [certFile, password, certType, token]);
|
||||
|
||||
const handleSubmitSignature = async () => {
|
||||
if (!certFile && certType !== "SERVER") {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: "Please select a certificate file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setNotification(null);
|
||||
try {
|
||||
await submitSignature({
|
||||
participantToken: token,
|
||||
certType,
|
||||
password,
|
||||
p12File: certType === "P12" ? certFile || undefined : undefined,
|
||||
jksFile: certType === "JKS" ? certFile || undefined : undefined,
|
||||
showSignature,
|
||||
pageNumber,
|
||||
location,
|
||||
reason,
|
||||
showLogo: true,
|
||||
});
|
||||
setNotification({
|
||||
type: "success",
|
||||
message: "Signature submitted successfully!",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: `Failed to submit signature: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (
|
||||
window.confirm("Are you sure you want to decline signing this document?")
|
||||
) {
|
||||
setNotification(null);
|
||||
try {
|
||||
await decline(token, declineReason || "Declined by participant");
|
||||
setNotification({
|
||||
type: "success",
|
||||
message: "You have declined this signing request.",
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setNotification({
|
||||
type: "error",
|
||||
message: `Failed to decline: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !session) {
|
||||
return (
|
||||
<Stack align="center" justify="center" p="xl">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed">Loading session...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || !participant) {
|
||||
return (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||
Session not found or access denied.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "SIGNED":
|
||||
return <Badge color="green">Signed</Badge>;
|
||||
case "DECLINED":
|
||||
return <Badge color="red">Declined</Badge>;
|
||||
case "VIEWED":
|
||||
return <Badge color="blue">Viewed</Badge>;
|
||||
case "NOTIFIED":
|
||||
return <Badge color="yellow">Notified</Badge>;
|
||||
case "PENDING":
|
||||
return <Badge color="gray">Pending</Badge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const canSign =
|
||||
!participant.hasCompleted &&
|
||||
!participant.isExpired &&
|
||||
session.status === "IN_PROGRESS";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{notification && (
|
||||
<Alert
|
||||
icon={
|
||||
notification.type === "success" ? (
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
) : (
|
||||
<InfoIcon fontSize="small" />
|
||||
)
|
||||
}
|
||||
color={notification.type === "success" ? "green" : "red"}
|
||||
withCloseButton
|
||||
onClose={() => setNotification(null)}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
)}
|
||||
<Card shadow="sm" padding="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={500} size="lg">
|
||||
{session.documentName}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
From: {session.ownerUsername}
|
||||
</Text>
|
||||
</div>
|
||||
{getStatusBadge(participant.status)}
|
||||
</Group>
|
||||
|
||||
{session.message && (
|
||||
<Alert
|
||||
icon={<InfoIcon fontSize="small" />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">{session.message}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{session.dueDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Due Date: {session.dueDate}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<DownloadIcon fontSize="small" />}
|
||||
onClick={() => downloadDocument(token)}
|
||||
variant="light"
|
||||
>
|
||||
Download Document
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{canSign && (
|
||||
<Card shadow="sm" padding="md" radius="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={500} size="lg">
|
||||
Sign Document
|
||||
</Text>
|
||||
|
||||
<Select
|
||||
label="Certificate Type"
|
||||
value={certType}
|
||||
onChange={(value) => setCertType(value || "P12")}
|
||||
data={[
|
||||
{ value: "P12", label: "P12/PKCS12 Certificate" },
|
||||
{ value: "JKS", label: "JKS Keystore" },
|
||||
{ value: "SERVER", label: "Server Certificate (if available)" },
|
||||
]}
|
||||
size="sm"
|
||||
data-testid="cert-type-select"
|
||||
/>
|
||||
|
||||
{certType !== "SERVER" && (
|
||||
<>
|
||||
<FileInput
|
||||
label="Certificate File"
|
||||
placeholder="Select certificate file"
|
||||
value={certFile}
|
||||
onChange={setCertFile}
|
||||
accept=".p12,.pfx,.jks"
|
||||
size="sm"
|
||||
data-testid="cert-file-input"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Certificate Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
size="sm"
|
||||
data-testid="cert-password-input"
|
||||
/>
|
||||
|
||||
{/* Certificate validation feedback */}
|
||||
{certValidation.status === "validating" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certValidating",
|
||||
"Validating certificate...",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{certValidation.status === "valid" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="green"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certValid",
|
||||
"✓ Certificate valid",
|
||||
)}
|
||||
{certValidation.notAfter
|
||||
? t(
|
||||
"certSign.collab.participant.certValidUntil",
|
||||
" until {{date}}",
|
||||
{
|
||||
date: new Date(
|
||||
certValidation.notAfter,
|
||||
).toLocaleDateString(),
|
||||
},
|
||||
)
|
||||
: ""}
|
||||
{certValidation.subjectName
|
||||
? ` · ${certValidation.subjectName}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{certValidation.status === "error" && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="red"
|
||||
data-testid="cert-validation-feedback"
|
||||
>
|
||||
{t(
|
||||
"certSign.collab.participant.certInvalid",
|
||||
"✗ {{error}}",
|
||||
{ error: certValidation.message },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="e.g., San Francisco, CA"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder="e.g., Document approval"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Page Number (optional)"
|
||||
type="number"
|
||||
value={pageNumber}
|
||||
onChange={(e) =>
|
||||
setPageNumber(parseInt(e.currentTarget.value) || 1)
|
||||
}
|
||||
size="sm"
|
||||
min={1}
|
||||
/>
|
||||
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon fontSize="small" />}
|
||||
onClick={handleSubmitSignature}
|
||||
loading={isSubmitting}
|
||||
disabled={
|
||||
isSubmitting || certValidation.status === "validating"
|
||||
}
|
||||
color="green"
|
||||
data-testid="submit-signature-button"
|
||||
>
|
||||
Submit Signature
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
onClick={handleDecline}
|
||||
color="red"
|
||||
variant="light"
|
||||
data-testid="decline-button"
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{participant.hasCompleted && (
|
||||
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
|
||||
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
|
||||
this document.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{participant.isExpired && (
|
||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||
Your access to this document has expired.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticipantView;
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { PlanFeature } from "@app/types/license";
|
||||
|
||||
/**
|
||||
* Shared plan feature definitions for Stirling PDF Self-Hosted
|
||||
* Used by both dynamic (Stripe) and static (fallback) plan displays
|
||||
*/
|
||||
|
||||
export const PLAN_FEATURES = {
|
||||
FREE: [
|
||||
{ name: "Self-hosted deployment", included: true },
|
||||
{ name: "All PDF operations", included: true },
|
||||
{ name: "Secure Login Support", included: true },
|
||||
{ name: "Community support", included: true },
|
||||
{ name: "Regular updates", included: true },
|
||||
{ name: "up to 5 users", included: true },
|
||||
{ name: "Unlimited users", included: false },
|
||||
{ name: "Google drive integration", included: false },
|
||||
{ name: "External Database", included: false },
|
||||
{ name: "Editing text in pdfs", included: false },
|
||||
{ name: "Users limited to seats", included: false },
|
||||
{ name: "SSO", included: false },
|
||||
{ name: "SAML", included: false },
|
||||
{ name: "Auditing", included: false },
|
||||
{ name: "Usage tracking", included: false },
|
||||
{ name: "Prometheus Support", included: false },
|
||||
{ name: "Custom PDF metadata", included: false },
|
||||
] as PlanFeature[],
|
||||
|
||||
SERVER: [
|
||||
{ name: "Self-hosted deployment", included: true },
|
||||
{ name: "All PDF operations", included: true },
|
||||
{ name: "Secure Login Support", included: true },
|
||||
{ name: "Community support", included: true },
|
||||
{ name: "Regular updates", included: true },
|
||||
{ name: "Up to 5 users", included: false },
|
||||
{ name: "Unlimited users", included: true },
|
||||
{ name: "Google drive integration", included: true },
|
||||
{ name: "External Database", included: true },
|
||||
{ name: "Editing text in pdfs", included: true },
|
||||
{ name: "Users limited to seats", included: false },
|
||||
{ name: "SSO", included: true },
|
||||
{ name: "SAML", included: false },
|
||||
{ name: "Auditing", included: false },
|
||||
{ name: "Usage tracking", included: false },
|
||||
{ name: "Prometheus Support", included: false },
|
||||
{ name: "Custom PDF metadata", included: false },
|
||||
] as PlanFeature[],
|
||||
|
||||
ENTERPRISE: [
|
||||
{ name: "Self-hosted deployment", included: true },
|
||||
{ name: "All PDF operations", included: true },
|
||||
{ name: "Secure Login Support", included: true },
|
||||
{ name: "Community support", included: true },
|
||||
{ name: "Regular updates", included: true },
|
||||
{ name: "up to 5 users", included: false },
|
||||
{ name: "Unlimited users", included: false },
|
||||
{ name: "Google drive integration", included: true },
|
||||
{ name: "External Database", included: true },
|
||||
{ name: "Editing text in pdfs", included: true },
|
||||
{ name: "Users limited to seats", included: true },
|
||||
{ name: "SSO", included: true },
|
||||
{ name: "SAML", included: true },
|
||||
{ name: "Auditing", included: true },
|
||||
{ name: "Usage tracking", included: true },
|
||||
{ name: "Prometheus Support", included: true },
|
||||
{ name: "Custom PDF metadata", included: true },
|
||||
] as PlanFeature[],
|
||||
} as const;
|
||||
|
||||
export const PLAN_HIGHLIGHTS = {
|
||||
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
|
||||
SERVER_MONTHLY: [
|
||||
"Self-hosted on your infrastructure",
|
||||
"Unlimited users",
|
||||
"Advanced integrations",
|
||||
"SSO (OAuth2/OIDC)",
|
||||
"Editing text in PDFs",
|
||||
"Cancel anytime",
|
||||
],
|
||||
SERVER_YEARLY: [
|
||||
"Self-hosted on your infrastructure",
|
||||
"Unlimited users",
|
||||
"Advanced integrations",
|
||||
"SSO (OAuth2/OIDC)",
|
||||
"Editing text in PDFs",
|
||||
"Save with annual billing",
|
||||
],
|
||||
ENTERPRISE_MONTHLY: [
|
||||
"Enterprise features (SAML, Auditing)",
|
||||
"Usage tracking & Prometheus",
|
||||
"Custom PDF metadata",
|
||||
"Per-seat licensing",
|
||||
],
|
||||
ENTERPRISE_YEARLY: [
|
||||
"Enterprise features (SAML, Auditing)",
|
||||
"Usage tracking & Prometheus",
|
||||
"Custom PDF metadata",
|
||||
"Save with annual billing",
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Static Stripe payment links for offline/self-hosted environments
|
||||
*
|
||||
* These links are used when Supabase is not configured, allowing users to
|
||||
* purchase licenses directly through Stripe hosted checkout pages.
|
||||
*
|
||||
*/
|
||||
|
||||
export interface StaticStripeLinks {
|
||||
server: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
enterprise: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
billingPortal: string;
|
||||
}
|
||||
// PRODCUTION LINKS FOR LIVE SERVER
|
||||
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
|
||||
server: {
|
||||
monthly: "https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03",
|
||||
yearly: "https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02",
|
||||
},
|
||||
enterprise: {
|
||||
monthly: "",
|
||||
yearly: "",
|
||||
},
|
||||
billingPortal: "https://billing.stripe.com/p/login/5kA6pT6Xa7z59HO4gg",
|
||||
};
|
||||
|
||||
// LINKS FOR TEST SERVER:
|
||||
// export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
|
||||
// server: {
|
||||
// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02',
|
||||
// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03',
|
||||
// },
|
||||
// enterprise: {
|
||||
// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04',
|
||||
// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05',
|
||||
// },
|
||||
// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
|
||||
// };
|
||||
|
||||
/**
|
||||
* Builds a Stripe URL with a prefilled email parameter
|
||||
* @param baseUrl - The base Stripe checkout URL
|
||||
* @param email - The email address to prefill
|
||||
* @returns The complete URL with encoded email parameter
|
||||
*/
|
||||
export function buildStripeUrlWithEmail(
|
||||
baseUrl: string,
|
||||
email: string,
|
||||
): string {
|
||||
const encodedEmail = encodeURIComponent(email);
|
||||
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import licenseService, {
|
||||
PlanTierGroup,
|
||||
LicenseInfo,
|
||||
mapLicenseToTier,
|
||||
PlanTier,
|
||||
} from "@app/services/licenseService";
|
||||
import { StripeCheckout } from "@app/components/shared/stripeCheckout";
|
||||
import { userManagementService } from "@app/services/userManagementService";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
pollLicenseKeyWithBackoff,
|
||||
activateLicenseKey,
|
||||
resyncExistingLicense,
|
||||
} from "@app/utils/licenseCheckoutUtils";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import { isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||
import { getPreferredCurrency } from "@app/utils/currencyDetection";
|
||||
|
||||
export interface CheckoutOptions {
|
||||
minimumSeats?: number; // Override calculated seats for enterprise
|
||||
currency?: string; // Optional currency override (auto-detected from locale)
|
||||
onSuccess?: (sessionId: string) => void; // Callback after successful payment
|
||||
onError?: (error: string) => void; // Callback on error
|
||||
}
|
||||
|
||||
interface CheckoutContextValue {
|
||||
openCheckout: (
|
||||
tier: "server" | "enterprise",
|
||||
options?: CheckoutOptions,
|
||||
) => Promise<void>;
|
||||
closeCheckout: () => void;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CheckoutContext = createContext<CheckoutContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface CheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
children,
|
||||
defaultCurrency,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { refetchLicense } = useLicense();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPlanGroup, setSelectedPlanGroup] =
|
||||
useState<PlanTierGroup | null>(null);
|
||||
const [minimumSeats, setMinimumSeats] = useState<number>(1);
|
||||
const [currentCurrency, setCurrentCurrency] = useState(() => {
|
||||
// Use provided default or auto-detect from locale
|
||||
return defaultCurrency || getPreferredCurrency(i18n.language);
|
||||
});
|
||||
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
|
||||
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Lazy-loaded plans state (no fetch on mount)
|
||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||
const [plansLoaded, setPlansLoaded] = useState(false);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
// Lazy fetch plans only when needed
|
||||
const fetchPlansIfNeeded = useCallback(
|
||||
async (currency: string) => {
|
||||
// Don't fetch if already loading
|
||||
if (plansLoading) return;
|
||||
|
||||
try {
|
||||
setPlansLoading(true);
|
||||
const response = await licenseService.getPlans(currency);
|
||||
setPlans(response.plans);
|
||||
setPlansLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch plans:", error);
|
||||
// Don't block - let components handle the error
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
},
|
||||
[plansLoading],
|
||||
);
|
||||
|
||||
const refetchPlans = useCallback(() => {
|
||||
setPlansLoaded(false); // Force refetch
|
||||
return fetchPlansIfNeeded(currentCurrency);
|
||||
}, [currentCurrency, fetchPlansIfNeeded]);
|
||||
|
||||
// Handle return from hosted Stripe checkout
|
||||
useEffect(() => {
|
||||
const handleCheckoutReturn = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paymentStatus = urlParams.get("payment_status");
|
||||
const sessionId = urlParams.get("session_id");
|
||||
|
||||
if (paymentStatus === "success" && sessionId) {
|
||||
console.log("Payment successful via hosted checkout:", sessionId);
|
||||
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
|
||||
// Fetch current license info to determine upgrade vs new
|
||||
let licenseInfo: LicenseInfo | null = null;
|
||||
try {
|
||||
licenseInfo = await licenseService.getLicenseInfo();
|
||||
} catch (err) {
|
||||
console.warn("Could not fetch license info:", err);
|
||||
}
|
||||
|
||||
// Check if this is an upgrade or new subscription
|
||||
// Only treat as upgrade if there's a valid PRO/ENTERPRISE license (not NORMAL/free tier)
|
||||
if (licenseInfo?.licenseType && licenseInfo.licenseType !== "NORMAL") {
|
||||
// UPGRADE: Resync existing license with Keygen
|
||||
console.log("Upgrade detected - resyncing existing license");
|
||||
|
||||
const activation = await resyncExistingLicense();
|
||||
|
||||
if (activation.success) {
|
||||
console.log(
|
||||
"License synced successfully, refreshing license context",
|
||||
);
|
||||
|
||||
// Ensure plans are loaded before using them
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currentCurrency);
|
||||
}
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
|
||||
// Determine tier from license type
|
||||
const tier =
|
||||
activation.licenseType === "ENTERPRISE" ? "enterprise" : "server";
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
||||
|
||||
if (planGroup) {
|
||||
// Reopen modal to show success
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setHostedCheckoutSuccess({ isUpgrade: true });
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
// Fallback to toast if plan group not found
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("payment.upgradeSuccess"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Failed to sync license after upgrade:",
|
||||
activation.error,
|
||||
);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("payment.syncError"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// NEW SUBSCRIPTION: Poll for license key
|
||||
console.log("New subscription - polling for license key");
|
||||
|
||||
try {
|
||||
const installationId = await licenseService.getInstallationId();
|
||||
console.log(
|
||||
"Polling for license key with installation ID:",
|
||||
installationId,
|
||||
);
|
||||
|
||||
// Use shared polling utility
|
||||
const result = await pollLicenseKeyWithBackoff(installationId);
|
||||
|
||||
if (result.success && result.licenseKey) {
|
||||
// Activate the license key
|
||||
const activation = await activateLicenseKey(result.licenseKey);
|
||||
|
||||
if (activation.success) {
|
||||
console.log(`License key activated: ${activation.licenseType}`);
|
||||
|
||||
// Ensure plans are loaded before using them
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currentCurrency);
|
||||
}
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
|
||||
// Determine tier from license type
|
||||
const tier =
|
||||
activation.licenseType === "ENTERPRISE"
|
||||
? "enterprise"
|
||||
: "server";
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
||||
|
||||
if (planGroup) {
|
||||
// Reopen modal to show success with license key
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setHostedCheckoutSuccess({
|
||||
isUpgrade: false,
|
||||
licenseKey: result.licenseKey,
|
||||
});
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
// Fallback to toast if plan group not found
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("payment.licenseActivated"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to save license key:", activation.error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("payment.licenseSaveError"),
|
||||
});
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
console.warn("License key polling timed out");
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("payment.licenseDelayed"),
|
||||
});
|
||||
} else {
|
||||
console.error("License key polling failed:", result.error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("payment.licensePollingError"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to poll for license key:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("payment.licenseRetrievalError"),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (paymentStatus === "canceled") {
|
||||
console.log("Payment canceled by user");
|
||||
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("payment.paymentCanceled"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCheckoutReturn();
|
||||
}, [
|
||||
t,
|
||||
refetchPlans,
|
||||
refetchLicense,
|
||||
plans,
|
||||
fetchPlansIfNeeded,
|
||||
plansLoaded,
|
||||
currentCurrency,
|
||||
]);
|
||||
|
||||
const openCheckout = useCallback(
|
||||
async (tier: "server" | "enterprise", options: CheckoutOptions = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured) {
|
||||
throw new Error(
|
||||
"Checkout is not available. Supabase is not configured.",
|
||||
);
|
||||
}
|
||||
|
||||
// Update currency if provided
|
||||
const currency = options.currency || currentCurrency;
|
||||
if (currency !== currentCurrency) {
|
||||
setCurrentCurrency(currency);
|
||||
}
|
||||
|
||||
// Fetch plans if not already loaded
|
||||
if (!plansLoaded) {
|
||||
await fetchPlansIfNeeded(currency);
|
||||
}
|
||||
|
||||
// Fetch license info and user data for seat calculations
|
||||
let licenseInfo: LicenseInfo | null = null;
|
||||
let totalUsers = 0;
|
||||
|
||||
try {
|
||||
const [licenseData, userData] = await Promise.all([
|
||||
licenseService.getLicenseInfo(),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
|
||||
licenseInfo = licenseData;
|
||||
totalUsers = userData.totalUsers || 0;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Could not fetch license/user info, proceeding with defaults:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate minimum seats for enterprise upgrades
|
||||
let calculatedMinSeats = options.minimumSeats || 1;
|
||||
|
||||
if (tier === "enterprise" && !options.minimumSeats) {
|
||||
const currentTier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
if (currentTier === "server" || currentTier === "free") {
|
||||
// Upgrading from Server (unlimited) to Enterprise (per-seat)
|
||||
// Use current total user count as minimum
|
||||
calculatedMinSeats = Math.max(totalUsers, 1);
|
||||
console.log(
|
||||
`Setting minimum seats from server user count: ${calculatedMinSeats}`,
|
||||
);
|
||||
} else if (currentTier === "enterprise") {
|
||||
// Upgrading within Enterprise (e.g., monthly to yearly)
|
||||
// Use current licensed seat count as minimum
|
||||
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
|
||||
console.log(
|
||||
`Setting minimum seats from current license: ${calculatedMinSeats}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the plan group for the requested tier
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find((pg) => pg.tier === tier);
|
||||
|
||||
if (!planGroup) {
|
||||
throw new Error(`No ${tier} plan available`);
|
||||
}
|
||||
|
||||
// Store options for callbacks
|
||||
setCurrentOptions(options);
|
||||
setMinimumSeats(calculatedMinSeats);
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setIsOpen(true);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to open checkout";
|
||||
console.error("Error opening checkout:", errorMessage);
|
||||
options.onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[currentCurrency, plans, plansLoaded, fetchPlansIfNeeded],
|
||||
);
|
||||
|
||||
const closeCheckout = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedPlanGroup(null);
|
||||
setCurrentOptions({});
|
||||
setHostedCheckoutSuccess(null);
|
||||
|
||||
// Refetch plans and license after modal closes to update subscription display
|
||||
refetchPlans();
|
||||
refetchLicense();
|
||||
}, [refetchPlans, refetchLicense]);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log("Payment successful, session:", sessionId);
|
||||
currentOptions.onSuccess?.(sessionId);
|
||||
// Don't close modal - let user view license key and close manually
|
||||
},
|
||||
[currentOptions],
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback(
|
||||
(error: string) => {
|
||||
console.error("Payment error:", error);
|
||||
currentOptions.onError?.(error);
|
||||
},
|
||||
[currentOptions],
|
||||
);
|
||||
|
||||
const handleLicenseActivated = useCallback(
|
||||
(licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => {
|
||||
console.log("License activated:", licenseInfo);
|
||||
// Could expose this via context if needed
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue: CheckoutContextValue = {
|
||||
openCheckout,
|
||||
closeCheckout,
|
||||
isOpen,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<CheckoutContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
{/* Global Checkout Modal */}
|
||||
{selectedPlanGroup && (
|
||||
<StripeCheckout
|
||||
opened={isOpen}
|
||||
onClose={closeCheckout}
|
||||
planGroup={selectedPlanGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
onLicenseActivated={handleLicenseActivated}
|
||||
hostedCheckoutSuccess={hostedCheckoutSuccess}
|
||||
/>
|
||||
)}
|
||||
</CheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCheckout = (): CheckoutContextValue => {
|
||||
const context = useContext(CheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error("useCheckout must be used within CheckoutProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import licenseService, { LicenseInfo } from "@app/services/licenseService";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { getSimulatedLicenseInfo } from "@app/testing/serverExperienceSimulations";
|
||||
|
||||
interface LicenseContextValue {
|
||||
licenseInfo: LicenseInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetchLicense: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LicenseContext = createContext<LicenseContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface LicenseProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LicenseProvider: React.FC<LicenseProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { config } = useAppConfig();
|
||||
const location = useLocation();
|
||||
const configRef = useRef(config);
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Keep ref updated with latest config
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
const refetchLicense = useCallback(async () => {
|
||||
// Wait for config to load if it's not available yet
|
||||
let currentConfig = configRef.current;
|
||||
if (!currentConfig) {
|
||||
console.log("[LicenseContext] Config not loaded yet, waiting...");
|
||||
// OPTIMIZATION: Reduced from 5s to 1s - config should load quickly
|
||||
const maxWait = 1000;
|
||||
const startTime = Date.now();
|
||||
while (!configRef.current && Date.now() - startTime < maxWait) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
currentConfig = configRef.current;
|
||||
}
|
||||
|
||||
if (!currentConfig) {
|
||||
console.error("[LicenseContext] Config failed to load after waiting");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch license info if user is an admin
|
||||
if (!currentConfig.isAdmin) {
|
||||
console.log(
|
||||
"[LicenseContext] User is not an admin, skipping license fetch",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[LicenseContext] Fetching license info");
|
||||
|
||||
try {
|
||||
const testInfo = getSimulatedLicenseInfo();
|
||||
if (testInfo) {
|
||||
setLicenseInfo(testInfo);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const info = await licenseService.getLicenseInfo();
|
||||
setLicenseInfo(info);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to fetch license info";
|
||||
console.error("Error fetching license info:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setLicenseInfo(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch license info when config changes (only if user is admin)
|
||||
useEffect(() => {
|
||||
// CRITICAL FIX: Skip license fetch on auth routes to prevent race conditions
|
||||
// during SAML/OAuth callback processing. License isn't needed until user
|
||||
// is authenticated and navigates to main app.
|
||||
const isAuthRoute =
|
||||
location.pathname === "/login" ||
|
||||
location.pathname === "/signup" ||
|
||||
location.pathname === "/auth/callback" ||
|
||||
location.pathname.startsWith("/invite/");
|
||||
|
||||
if (isAuthRoute) {
|
||||
console.log("[LicenseContext] On auth route, skipping license fetch");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
refetchLicense();
|
||||
}
|
||||
}, [config, refetchLicense, location.pathname]);
|
||||
|
||||
const contextValue: LicenseContextValue = useMemo(
|
||||
() => ({
|
||||
licenseInfo,
|
||||
loading,
|
||||
error,
|
||||
refetchLicense,
|
||||
}),
|
||||
[licenseInfo, loading, error, refetchLicense],
|
||||
);
|
||||
|
||||
return (
|
||||
<LicenseContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</LicenseContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOptionalLicense = (): LicenseContextValue | undefined => {
|
||||
return useContext(LicenseContext);
|
||||
};
|
||||
|
||||
export const useLicense = (): LicenseContextValue => {
|
||||
const context = useContext(LicenseContext);
|
||||
if (!context) {
|
||||
throw new Error("useLicense must be used within LicenseProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import {
|
||||
getSimulatedAdminUsage,
|
||||
getSimulatedWauResponse,
|
||||
} from "@app/testing/serverExperienceSimulations";
|
||||
|
||||
const SELF_REPORTED_ADMIN_KEY = "stirling-self-reported-admin";
|
||||
const FREE_TIER_LIMIT = 5;
|
||||
|
||||
type UserCountSource = "admin" | "estimate" | "unknown";
|
||||
|
||||
interface WeeklyActiveUsersResponse {
|
||||
trackingSince: string;
|
||||
daysOnline: number;
|
||||
totalUniqueBrowsers: number;
|
||||
weeklyActiveUsers: number;
|
||||
}
|
||||
|
||||
interface UserCountState {
|
||||
totalUsers: number | null;
|
||||
weeklyActiveUsers: number | null;
|
||||
loading: boolean;
|
||||
source: UserCountSource;
|
||||
lastUpdated: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type ServerScenarioKey =
|
||||
| "unknown"
|
||||
| "licensed"
|
||||
| "no-login-user-under-limit-no-license"
|
||||
| "no-login-admin-under-limit-no-license"
|
||||
| "no-login-user-over-limit-no-license"
|
||||
| "no-login-admin-over-limit-no-license"
|
||||
| "login-user-under-limit-no-license"
|
||||
| "login-admin-under-limit-no-license"
|
||||
| "login-user-over-limit-no-license"
|
||||
| "login-admin-over-limit-no-license";
|
||||
|
||||
export interface ServerExperienceValue {
|
||||
loginEnabled: boolean;
|
||||
configIsAdmin: boolean;
|
||||
effectiveIsAdmin: boolean;
|
||||
selfReportedAdmin: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isNewServer: boolean | null;
|
||||
isNewUser: boolean | null;
|
||||
premiumEnabled: boolean | null;
|
||||
license: string | undefined;
|
||||
runningProOrHigher: boolean | undefined;
|
||||
runningEE: boolean | undefined;
|
||||
hasPaidLicense: boolean;
|
||||
licenseKeyValid: boolean | null;
|
||||
licenseLoading: boolean;
|
||||
licenseInfoAvailable: boolean;
|
||||
totalUsers: number | null;
|
||||
weeklyActiveUsers: number | null;
|
||||
userCountLoading: boolean;
|
||||
userCountError: string | null;
|
||||
userCountSource: UserCountSource;
|
||||
userCountResolved: boolean;
|
||||
overFreeTierLimit: boolean | null;
|
||||
freeTierLimit: number;
|
||||
refreshUserCounts: () => Promise<void>;
|
||||
setSelfReportedAdmin: (value: boolean) => void;
|
||||
scenarioKey: ServerScenarioKey;
|
||||
}
|
||||
|
||||
const ServerExperienceContext = createContext<
|
||||
ServerExperienceValue | undefined
|
||||
>(undefined);
|
||||
|
||||
function getStoredSelfReportedAdmin(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
typeof error.response?.data?.message === "string"
|
||||
) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Unable to load server usage";
|
||||
}
|
||||
|
||||
export function ServerExperienceProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { config } = useAppConfig();
|
||||
const { user } = useAuth();
|
||||
const { licenseInfo, loading: licenseLoading } = useLicense();
|
||||
|
||||
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(
|
||||
getStoredSelfReportedAdmin,
|
||||
);
|
||||
const [userCountState, setUserCountState] = useState<UserCountState>({
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: "unknown",
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
const configIsAdmin = Boolean(config?.isAdmin);
|
||||
const effectiveIsAdmin =
|
||||
configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
const isAuthenticated = Boolean(user);
|
||||
|
||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||
setSelfReportedAdminState(value);
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (value) {
|
||||
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, "true");
|
||||
} else {
|
||||
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === SELF_REPORTED_ADMIN_KEY) {
|
||||
setSelfReportedAdminState(event.newValue === "true");
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
|
||||
setSelfReportedAdmin(true);
|
||||
}
|
||||
}, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
|
||||
|
||||
const fetchUserCounts = useCallback(async () => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (!config.appVersion) {
|
||||
return;
|
||||
}
|
||||
if (loginEnabled && !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
|
||||
// Use WAU estimate for no-login scenarios OR for login non-admin users
|
||||
const shouldUseEstimate = config.enableLogin === false || !config.isAdmin;
|
||||
|
||||
setUserCountState((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
if (shouldUseAdminData) {
|
||||
const testResponse = getSimulatedAdminUsage();
|
||||
const responseData =
|
||||
testResponse ??
|
||||
(
|
||||
await apiClient.get<{ totalUsers?: number }>(
|
||||
"/api/v1/proprietary/ui-data/admin-settings",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
)
|
||||
).data;
|
||||
const totalUsers =
|
||||
typeof responseData?.totalUsers === "number"
|
||||
? responseData.totalUsers
|
||||
: null;
|
||||
setUserCountState({
|
||||
totalUsers,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: "admin",
|
||||
lastUpdated: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUseEstimate) {
|
||||
const testResponse = getSimulatedWauResponse();
|
||||
const responseData =
|
||||
testResponse ??
|
||||
(
|
||||
await apiClient.get<WeeklyActiveUsersResponse>("/api/v1/info/wau", {
|
||||
suppressErrorToast: true,
|
||||
})
|
||||
).data;
|
||||
const weeklyActiveUsers =
|
||||
typeof responseData?.weeklyActiveUsers === "number"
|
||||
? responseData.weeklyActiveUsers
|
||||
: null;
|
||||
setUserCountState({
|
||||
totalUsers: weeklyActiveUsers,
|
||||
weeklyActiveUsers,
|
||||
loading: false,
|
||||
source: "estimate",
|
||||
lastUpdated: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setUserCountState({
|
||||
totalUsers: null,
|
||||
weeklyActiveUsers: null,
|
||||
loading: false,
|
||||
source: "unknown",
|
||||
lastUpdated: null,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUserCounts();
|
||||
}, [fetchUserCounts]);
|
||||
|
||||
const refreshUserCounts = useCallback(async () => {
|
||||
await fetchUserCounts();
|
||||
}, [fetchUserCounts]);
|
||||
|
||||
const hasPaidLicense = useMemo(() => {
|
||||
return (
|
||||
config?.license === "SERVER" ||
|
||||
config?.license === "PRO" ||
|
||||
config?.license === "ENTERPRISE"
|
||||
);
|
||||
}, [config?.license]);
|
||||
|
||||
const licenseKeyValid = useMemo(() => {
|
||||
if (licenseInfo) {
|
||||
return licenseInfo.hasKey && licenseInfo.enabled;
|
||||
}
|
||||
if (config?.premiumEnabled) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}, [config?.premiumEnabled, licenseInfo]);
|
||||
|
||||
const overFreeTierLimit = useMemo(() => {
|
||||
if (typeof userCountState.totalUsers !== "number") {
|
||||
return null;
|
||||
}
|
||||
return userCountState.totalUsers > FREE_TIER_LIMIT;
|
||||
}, [userCountState.totalUsers]);
|
||||
|
||||
const userCountResolved =
|
||||
!userCountState.loading &&
|
||||
userCountState.source !== "unknown" &&
|
||||
userCountState.totalUsers !== null;
|
||||
|
||||
const scenarioKey = useMemo<ServerScenarioKey>(() => {
|
||||
if (hasPaidLicense) {
|
||||
return "licensed";
|
||||
}
|
||||
if (!userCountResolved || typeof userCountState.totalUsers !== "number") {
|
||||
return "unknown";
|
||||
}
|
||||
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
|
||||
|
||||
if (!loginEnabled) {
|
||||
if (selfReportedAdmin) {
|
||||
return overLimit
|
||||
? "no-login-admin-over-limit-no-license"
|
||||
: "no-login-admin-under-limit-no-license";
|
||||
}
|
||||
return overLimit
|
||||
? "no-login-user-over-limit-no-license"
|
||||
: "no-login-user-under-limit-no-license";
|
||||
}
|
||||
|
||||
if (configIsAdmin) {
|
||||
return overLimit
|
||||
? "login-admin-over-limit-no-license"
|
||||
: "login-admin-under-limit-no-license";
|
||||
}
|
||||
|
||||
return overLimit
|
||||
? "login-user-over-limit-no-license"
|
||||
: "login-user-under-limit-no-license";
|
||||
}, [
|
||||
hasPaidLicense,
|
||||
userCountResolved,
|
||||
userCountState.totalUsers,
|
||||
loginEnabled,
|
||||
selfReportedAdmin,
|
||||
configIsAdmin,
|
||||
]);
|
||||
|
||||
const value: ServerExperienceValue = {
|
||||
loginEnabled,
|
||||
configIsAdmin,
|
||||
effectiveIsAdmin,
|
||||
selfReportedAdmin,
|
||||
isAuthenticated,
|
||||
isNewServer: config?.isNewServer ?? null,
|
||||
isNewUser: config?.isNewUser ?? null,
|
||||
premiumEnabled: config?.premiumEnabled ?? null,
|
||||
license: config?.license,
|
||||
runningProOrHigher: config?.runningProOrHigher,
|
||||
runningEE: config?.runningEE,
|
||||
hasPaidLicense,
|
||||
licenseKeyValid,
|
||||
licenseLoading,
|
||||
licenseInfoAvailable: Boolean(licenseInfo),
|
||||
totalUsers: userCountState.totalUsers,
|
||||
weeklyActiveUsers: userCountState.weeklyActiveUsers,
|
||||
userCountLoading: userCountState.loading,
|
||||
userCountError: userCountState.error,
|
||||
userCountSource: userCountState.source,
|
||||
userCountResolved,
|
||||
overFreeTierLimit,
|
||||
freeTierLimit: FREE_TIER_LIMIT,
|
||||
refreshUserCounts,
|
||||
setSelfReportedAdmin,
|
||||
scenarioKey,
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerExperienceContext.Provider value={value}>
|
||||
{children}
|
||||
</ServerExperienceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useServerExperienceContext() {
|
||||
const context = useContext(ServerExperienceContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useServerExperience must be used within ServerExperienceProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import licenseService from "@app/services/licenseService";
|
||||
import UpdateSeatsModal from "@app/components/shared/UpdateSeatsModal";
|
||||
import { userManagementService } from "@app/services/userManagementService";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useOptionalLicense } from "@app/contexts/LicenseContext";
|
||||
import { resyncExistingLicense } from "@app/utils/licenseCheckoutUtils";
|
||||
|
||||
export interface UpdateSeatsOptions {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
interface UpdateSeatsContextValue {
|
||||
openUpdateSeats: (options?: UpdateSeatsOptions) => Promise<void>;
|
||||
closeUpdateSeats: () => void;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface UpdateSeatsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
// Use optional hook - won't throw during setup wizard when license provider isn't needed
|
||||
const license = useOptionalLicense();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentSeats, setCurrentSeats] = useState<number>(1);
|
||||
const [minimumSeats, setMinimumSeats] = useState<number>(1);
|
||||
const [currentOptions, setCurrentOptions] = useState<UpdateSeatsOptions>({});
|
||||
|
||||
// Handle return from Stripe billing portal
|
||||
useEffect(() => {
|
||||
// CRITICAL FIX: Don't run billing check on auth routes to prevent race conditions
|
||||
// during SAML/OAuth callback. This check only matters after successful billing
|
||||
// portal redirects, which never happen on auth routes.
|
||||
const isAuthRoute =
|
||||
location.pathname === "/login" ||
|
||||
location.pathname === "/signup" ||
|
||||
location.pathname === "/auth/callback" ||
|
||||
location.pathname.startsWith("/invite/");
|
||||
|
||||
if (isAuthRoute) {
|
||||
console.log(
|
||||
"[UpdateSeatsContext] On auth route, skipping billing return check",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBillingReturn = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const seatsUpdated = urlParams.get("seats_updated");
|
||||
|
||||
if (seatsUpdated === "true") {
|
||||
console.log("Seats updated successfully, syncing license with Keygen");
|
||||
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
|
||||
try {
|
||||
// Wait a moment for Stripe webhook to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Resync license with Keygen (not just local fetch)
|
||||
console.log("Seat update detected - resyncing license with Keygen");
|
||||
const activation = await resyncExistingLicense();
|
||||
|
||||
if (activation.success) {
|
||||
console.log("License synced successfully after seat update");
|
||||
|
||||
// Refresh global license context
|
||||
await license?.refetchLicense();
|
||||
|
||||
// Get updated license info for notification
|
||||
const updatedLicense = await licenseService.getLicenseInfo();
|
||||
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: t("billing.seatsUpdated", "Seats Updated"),
|
||||
body: t(
|
||||
"billing.seatsUpdatedMessage",
|
||||
"Your enterprise seats have been updated to {{seats}}",
|
||||
{
|
||||
seats: updatedLicense.maxUsers,
|
||||
},
|
||||
),
|
||||
});
|
||||
} else {
|
||||
throw new Error(activation.error || "Failed to sync license");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to sync license after seat update:", error);
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("billing.updateProcessing", "Update Processing"),
|
||||
body: t(
|
||||
"billing.updateProcessingMessage",
|
||||
"Your seat update is being processed. Please refresh in a few moments.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// CRITICAL FIX: Properly handle async function and catch errors
|
||||
handleBillingReturn().catch((error) => {
|
||||
console.error(
|
||||
"[UpdateSeatsContext] Error in billing return handler:",
|
||||
error,
|
||||
);
|
||||
// Don't throw - this is initialization, should not block rendering
|
||||
});
|
||||
}, [t, location.pathname, license]);
|
||||
|
||||
const openUpdateSeats = useCallback(
|
||||
async (options: UpdateSeatsOptions = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch current license info and user count
|
||||
const [licenseInfo, userData] = await Promise.all([
|
||||
licenseService.getLicenseInfo(),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
|
||||
// Validate this is an enterprise license
|
||||
if (!licenseInfo || licenseInfo.licenseType !== "ENTERPRISE") {
|
||||
throw new Error(
|
||||
t(
|
||||
"billing.notEnterprise",
|
||||
"Seat management is only available for enterprise licenses",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const currentLicenseSeats = licenseInfo.maxUsers || 1;
|
||||
const currentUserCount = userData.totalUsers || 0;
|
||||
|
||||
// Minimum seats must be at least the current number of users
|
||||
const calculatedMinSeats = Math.max(currentUserCount, 1);
|
||||
|
||||
console.log(
|
||||
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`,
|
||||
);
|
||||
|
||||
setCurrentSeats(currentLicenseSeats);
|
||||
setMinimumSeats(calculatedMinSeats);
|
||||
setCurrentOptions(options);
|
||||
setIsOpen(true);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to open seat update";
|
||||
console.error("Error opening seat update:", errorMessage);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("common.error", "Error"),
|
||||
body: errorMessage,
|
||||
});
|
||||
options.onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const closeUpdateSeats = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setCurrentOptions({});
|
||||
|
||||
// Refetch license after modal closes to update UI
|
||||
license?.refetchLicense();
|
||||
}, [license]);
|
||||
|
||||
const handleUpdateSeats = useCallback(
|
||||
async (newSeatCount: number): Promise<string> => {
|
||||
try {
|
||||
// Get current license key
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
if (!licenseInfo?.licenseKey) {
|
||||
throw new Error("No license key found");
|
||||
}
|
||||
|
||||
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
|
||||
|
||||
// Call manage-billing function with new seat count
|
||||
const portalUrl = await licenseService.updateEnterpriseSeats(
|
||||
newSeatCount,
|
||||
licenseInfo.licenseKey,
|
||||
);
|
||||
|
||||
return portalUrl;
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update seats";
|
||||
console.error("Error updating seats:", errorMessage);
|
||||
currentOptions.onError?.(errorMessage);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[currentSeats, currentOptions],
|
||||
);
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
console.log("Seat update initiated successfully");
|
||||
currentOptions.onSuccess?.();
|
||||
}, [currentOptions]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: string) => {
|
||||
console.error("Seat update error:", error);
|
||||
currentOptions.onError?.(error);
|
||||
},
|
||||
[currentOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<UpdateSeatsContext.Provider
|
||||
value={{
|
||||
openUpdateSeats,
|
||||
closeUpdateSeats,
|
||||
isOpen,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<UpdateSeatsModal
|
||||
opened={isOpen}
|
||||
onClose={closeUpdateSeats}
|
||||
currentSeats={currentSeats}
|
||||
minimumSeats={minimumSeats}
|
||||
_onSuccess={handleSuccess}
|
||||
onError={handleError}
|
||||
onUpdateSeats={handleUpdateSeats}
|
||||
/>
|
||||
</UpdateSeatsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateSeats = (): UpdateSeatsContextValue => {
|
||||
const context = useContext(UpdateSeatsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useUpdateSeats must be used within an UpdateSeatsProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default UpdateSeatsContext;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
|
||||
/**
|
||||
* Hook that provides the proprietary tool registry.
|
||||
*
|
||||
* This is the definition of all proprietary tools,
|
||||
* and will be included in the main tool registry.
|
||||
*/
|
||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
signOut: SignOutFn;
|
||||
redirectToLogin: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default (web/proprietary) logout handler: sign out and redirect to /login.
|
||||
* Desktop builds override this file via path resolution.
|
||||
*/
|
||||
export function useAccountLogout() {
|
||||
return async ({
|
||||
signOut,
|
||||
redirectToLogin,
|
||||
}: AccountLogoutDeps): Promise<void> => {
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem(
|
||||
"stirling_sso_auto_login_logged_out",
|
||||
"1",
|
||||
);
|
||||
}
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Extension hook for platform-specific OAuth callback handling.
|
||||
* Proprietary/web builds are no-op.
|
||||
*/
|
||||
export async function handleAuthCallbackSuccess(_token: string): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Extension hooks for platform-specific auth cleanup.
|
||||
* Proprietary/web builds are no-op.
|
||||
*/
|
||||
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Extension hook for platform-specific OAuth navigation.
|
||||
* Proprietary/web builds default to in-window navigation.
|
||||
*/
|
||||
export async function startOAuthNavigation(
|
||||
_redirectUrl: string,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface PlatformSessionUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no desktop SaaS auth bridge.
|
||||
*/
|
||||
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform user store.
|
||||
*/
|
||||
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform refresh path.
|
||||
*/
|
||||
export async function refreshPlatformSession(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
|
||||
*/
|
||||
export async function savePlatformToken(_token: string): Promise<void> {
|
||||
// Web mode: token already saved to localStorage in springAuthClient
|
||||
// No additional platform storage needed
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import licenseService, {
|
||||
PlanTier,
|
||||
PlansResponse,
|
||||
} from "@app/services/licenseService";
|
||||
|
||||
export interface UsePlansReturn {
|
||||
plans: PlanTier[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
|
||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: PlansResponse = await licenseService.getPlans(currency);
|
||||
setPlans(data.plans);
|
||||
} catch (err) {
|
||||
console.error("Error fetching plans:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch plans");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [currency]);
|
||||
|
||||
return {
|
||||
plans,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPlans,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export function useRequestHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem("stirling_jwt");
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useServerExperienceContext } from "@app/contexts/ServerExperienceContext";
|
||||
|
||||
export function useServerExperience() {
|
||||
return useServerExperienceContext();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
|
||||
export function useShouldShowWelcomeModal(): boolean {
|
||||
const { preferences } = usePreferences();
|
||||
const { session, loading } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Only show welcome modal if user is authenticated (session exists)
|
||||
// This prevents the modal from showing on login screens when security is enabled
|
||||
return (
|
||||
!loading &&
|
||||
!preferences.hasCompletedOnboarding &&
|
||||
preferences.toolPanelModePromptSeen &&
|
||||
!isMobile &&
|
||||
!!session
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import workflowService, {
|
||||
WorkflowSessionResponse,
|
||||
ParticipantResponse,
|
||||
SignatureSubmissionRequest,
|
||||
} from "@app/services/workflowService";
|
||||
|
||||
export interface UseParticipantSessionResult {
|
||||
session: WorkflowSessionResponse | null;
|
||||
participant: ParticipantResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
loadSession: (token: string) => Promise<void>;
|
||||
submitSignature: (request: SignatureSubmissionRequest) => Promise<void>;
|
||||
decline: (token: string, reason?: string) => Promise<void>;
|
||||
downloadDocument: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing workflow session from participant perspective
|
||||
*/
|
||||
export const useParticipantSession = (
|
||||
token?: string,
|
||||
): UseParticipantSessionResult => {
|
||||
const [session, setSession] = useState<WorkflowSessionResponse | null>(null);
|
||||
const [participant, setParticipant] = useState<ParticipantResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadSession = useCallback(async (token: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [sessionData, participantData] = await Promise.all([
|
||||
workflowService.getSessionByToken(token),
|
||||
workflowService.getParticipantDetails(token),
|
||||
]);
|
||||
setSession(sessionData);
|
||||
setParticipant(participantData);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = isAxiosError(err)
|
||||
? err.response?.data?.message || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
"Failed to load session";
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitSignature = useCallback(
|
||||
async (request: SignatureSubmissionRequest) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedParticipant =
|
||||
await workflowService.submitSignature(request);
|
||||
setParticipant(updatedParticipant);
|
||||
// Reload session to get updated status
|
||||
if (request.participantToken) {
|
||||
await loadSession(request.participantToken);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = isAxiosError(err)
|
||||
? err.response?.data?.message || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
"Failed to submit signature";
|
||||
setError(errorMsg);
|
||||
throw new Error(errorMsg, { cause: err });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[loadSession],
|
||||
);
|
||||
|
||||
const decline = useCallback(
|
||||
async (token: string, reason?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedParticipant = await workflowService.declineParticipation(
|
||||
token,
|
||||
reason,
|
||||
);
|
||||
setParticipant(updatedParticipant);
|
||||
// Reload session
|
||||
await loadSession(token);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = isAxiosError(err)
|
||||
? err.response?.data?.message || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
"Failed to decline";
|
||||
setError(errorMsg);
|
||||
throw new Error(errorMsg, { cause: err });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[loadSession],
|
||||
);
|
||||
|
||||
const downloadDocument = useCallback(
|
||||
async (token: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const pdfBlob = await workflowService.getParticipantDocument(token);
|
||||
const url = window.URL.createObjectURL(pdfBlob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = session?.documentName || "document.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = isAxiosError(err)
|
||||
? err.response?.data?.message || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
"Failed to download document";
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
// Auto-load session if token is provided
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadSession(token);
|
||||
}
|
||||
}, [token, loadSession]);
|
||||
|
||||
return {
|
||||
session,
|
||||
participant,
|
||||
loading,
|
||||
error,
|
||||
loadSession,
|
||||
submitSignature,
|
||||
decline,
|
||||
downloadDocument,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px 20px;
|
||||
background: #f5f5f5;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.iconSuccess {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.iconError {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.iconNeutral {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.loadingExtra {
|
||||
color: #6b7280;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
margin-top: 20px;
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #c62828;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
color: #e5e7eb;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.iconSuccess {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.iconError {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.iconNeutral {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.message,
|
||||
.loadingExtra {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
background: #3d2020;
|
||||
border: 1px solid #5d3030;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import {
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
|
||||
// Mock springAuth; keep the real redirect-path helpers.
|
||||
vi.mock("@app/auth/springAuthClient", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@app/auth/springAuthClient")
|
||||
>("@app/auth/springAuthClient");
|
||||
return {
|
||||
...actual,
|
||||
springAuth: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom");
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe("AuthCallback", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset window.location.hash
|
||||
window.location.hash = "";
|
||||
});
|
||||
|
||||
it("should extract JWT from URL hash and validate it", async () => {
|
||||
const mockToken = "oauth-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "[email protected]",
|
||||
username: "oauthuser",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
// Set URL hash with access token
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock successful session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
session: {
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify JWT was stored
|
||||
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
|
||||
|
||||
// Verify jwt-available event was dispatched
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "jwt-available" }),
|
||||
);
|
||||
|
||||
// Verify getSession was called to validate token
|
||||
expect(springAuth.getSession).toHaveBeenCalled();
|
||||
|
||||
// Verify navigation to home
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to login when no access token in hash", async () => {
|
||||
// No hash or empty hash
|
||||
window.location.hash = "";
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed - no token received." },
|
||||
});
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to login when token validation fails", async () => {
|
||||
const invalidToken = "invalid-oauth-token";
|
||||
window.location.hash = `#access_token=${invalidToken}`;
|
||||
|
||||
// Mock failed session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: { session: null },
|
||||
error: { message: "Invalid token" },
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// JWT should be stored initially
|
||||
expect(localStorage.getItem("stirling_jwt")).toBeNull(); // Cleared after validation failure
|
||||
|
||||
// Verify redirect to login
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed - invalid token." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
const mockToken = "error-token";
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock getSession throwing error
|
||||
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed. Please try again." },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to the stored post-login path when one is present", async () => {
|
||||
const mockToken = "oauth-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "[email protected]",
|
||||
username: "oauthuser",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/abc123");
|
||||
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
session: {
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/share/abc123", {
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
// Stored path is single-use
|
||||
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("should fall back to home when the stored post-login path is unsafe", async () => {
|
||||
const mockToken = "oauth-jwt-token";
|
||||
const mockUser = {
|
||||
id: "123",
|
||||
email: "[email protected]",
|
||||
username: "oauthuser",
|
||||
role: "USER",
|
||||
};
|
||||
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
// Protocol-relative URL — must not be followed
|
||||
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "//evil.example");
|
||||
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
session: {
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
|
||||
});
|
||||
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("should display loading state while processing", () => {
|
||||
window.location.hash = "#access_token=processing-token";
|
||||
|
||||
vi.mocked(springAuth.getSession).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: { session: null },
|
||||
error: { message: "Token expired" },
|
||||
}),
|
||||
100,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
expect(getByText("Completing authentication")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
consumePostLoginRedirectPath,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
|
||||
import styles from "@app/routes/AuthCallback.module.css";
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
*
|
||||
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
|
||||
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
|
||||
* We extract it, store in localStorage, and redirect to the home page.
|
||||
*/
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const processingRef = useRef(false);
|
||||
|
||||
// Log component lifecycle
|
||||
useEffect(() => {
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
|
||||
return () => {
|
||||
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const startTime = performance.now();
|
||||
const executionId = Math.random().toString(36).substring(7);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Starting authentication callback`,
|
||||
);
|
||||
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
|
||||
"1"
|
||||
) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "You have been signed out. Please sign in again." },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double execution (React 18 Strict Mode + navigate dependency)
|
||||
if (processingRef.current) {
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
|
||||
);
|
||||
console.warn(
|
||||
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
processingRef.current = true;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
|
||||
);
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get("access_token");
|
||||
|
||||
if (!token) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed - no token received." },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
|
||||
);
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
|
||||
);
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
|
||||
);
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (error || !data.session) {
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
|
||||
error,
|
||||
);
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed - invalid token." },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
|
||||
);
|
||||
|
||||
await handleAuthCallbackSuccess(token);
|
||||
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
|
||||
);
|
||||
|
||||
// Wait for all context providers to process jwt-available event
|
||||
// This prevents infinite render loop when coming from cross-domain SAML redirect
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
const target = consumePostLoginRedirectPath() ?? "/";
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
|
||||
);
|
||||
navigate(target, { replace: true });
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
|
||||
);
|
||||
console.log(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
|
||||
);
|
||||
console.error(`[AuthCallback:${executionId}] Error:`, error);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error name:`,
|
||||
(error as Error)?.name,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error message:`,
|
||||
(error as Error)?.message,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Error stack:`,
|
||||
(error as Error)?.stack,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
|
||||
);
|
||||
console.error(
|
||||
`[AuthCallback:${executionId}] ════════════════════════════════════`,
|
||||
);
|
||||
navigate("/login", {
|
||||
replace: true,
|
||||
state: { error: "OAuth login failed. Please try again." },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, []); // Empty deps - only run once on mount. navigate is stable, processingRef prevents double execution
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
|
||||
<div className={styles.title}>Completing authentication</div>
|
||||
<div className={styles.message}>
|
||||
Please wait while we finish signing you in.
|
||||
</div>
|
||||
<div className={styles.loadingExtra}>
|
||||
You can close this window once it completes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Paper,
|
||||
Center,
|
||||
Loader,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface InviteData {
|
||||
email: string | null;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
emailRequired: boolean;
|
||||
}
|
||||
|
||||
export default function InviteAccept() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
|
||||
description: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogTitle: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
|
||||
ogDescription: t(
|
||||
"app.description",
|
||||
"The Free Adobe Acrobat alternative (10M+ Downloads)",
|
||||
),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t("invite.invalidToken", "Invalid invitation link"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<InviteData>(
|
||||
`/api/v1/invite/validate/${token}`,
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
},
|
||||
);
|
||||
setInviteData(response.data);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = isAxiosError(err)
|
||||
? err.response?.data?.error || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
t("invite.validationError", "Failed to validate invitation link");
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate email if required
|
||||
if (inviteData?.emailRequired) {
|
||||
if (!email || email.trim().length === 0) {
|
||||
setError(t("invite.emailRequired", "Email address is required"));
|
||||
return;
|
||||
}
|
||||
if (!email.includes("@")) {
|
||||
setError(t("invite.invalidEmail", "Invalid email address"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate passwords
|
||||
if (!password) {
|
||||
setError(t("invite.passwordRequired", "Password is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t("invite.passwordMismatch", "Passwords do not match"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
if (inviteData?.emailRequired) {
|
||||
formData.append("email", email.trim().toLowerCase());
|
||||
}
|
||||
formData.append("password", password);
|
||||
|
||||
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
// Success - redirect to login
|
||||
navigate("/login?messageType=accountCreated");
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = isAxiosError(err)
|
||||
? err.response?.data?.error || err.message
|
||||
: (err instanceof Error ? err.message : undefined) ||
|
||||
t("invite.acceptError", "Failed to create account");
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t("invite.validating", "Validating invitation...")}
|
||||
/>
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !inviteData) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t("invite.invalidInvitation", "Invalid Invitation")}
|
||||
/>
|
||||
<ErrorMessage error={error} />
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
|
||||
>
|
||||
{t("invite.goToLogin", "Go to Login")}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t("invite.welcomeTitle", "You've been invited!")}
|
||||
subtitle={t(
|
||||
"invite.welcomeSubtitle",
|
||||
"Complete your account setup to get started",
|
||||
)}
|
||||
/>
|
||||
|
||||
{inviteData && !inviteData.emailRequired && (
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
mb="lg"
|
||||
bg="blue.0"
|
||||
style={{ borderColor: "var(--mantine-color-blue-3)" }}
|
||||
>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
fw={500}
|
||||
style={{ letterSpacing: "0.05em" }}
|
||||
>
|
||||
{t("invite.accountFor", "Creating account for")}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{inviteData.email}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("invite.linkExpires", "Link expires")}:{" "}
|
||||
{new Date(inviteData.expiresAt).toLocaleDateString()} at{" "}
|
||||
{new Date(inviteData.expiresAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<form onSubmit={handleAccept}>
|
||||
<Stack gap="md">
|
||||
{inviteData?.emailRequired && (
|
||||
<TextInput
|
||||
label={t("invite.email", "Email address")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t(
|
||||
"invite.emailPlaceholder",
|
||||
"Enter your email address",
|
||||
)}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t("invite.choosePassword", "Choose a password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("invite.passwordPlaceholder", "Enter your password")}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("invite.confirmPassword", "Confirm password")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t(
|
||||
"invite.confirmPasswordPlaceholder",
|
||||
"Re-enter your password",
|
||||
)}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{submitting
|
||||
? t("invite.creating", "Creating Account...")
|
||||
: t("invite.createAccount", "Create Account")}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Center mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("invite.alreadyHaveAccount", "Already have an account?")}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
c="dark"
|
||||
>
|
||||
{t("invite.signIn", "Sign in")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user