Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

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

  ### Why

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

  ### What moves

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

  ### Wiring edits (40 files)

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

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

  ### Verification

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

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

  ### Test plan

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,194 @@
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios";
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
function getJwtTokenFromStorage(): string | null {
try {
return localStorage.getItem("stirling_jwt");
} catch (error) {
console.error("[API Client] Failed to read JWT from localStorage:", error);
return null;
}
}
function setJwtTokenInStorage(token: string): void {
try {
localStorage.setItem("stirling_jwt", token);
console.debug("[API Client] Stored new JWT token in localStorage");
} catch (error) {
console.error("[API Client] Failed to store JWT in localStorage:", error);
}
}
function clearJwtTokenFromStorage(): void {
try {
localStorage.removeItem("stirling_jwt");
console.debug("[API Client] Cleared JWT token from localStorage");
} catch (error) {
console.error("[API Client] Failed to clear JWT from localStorage:", error);
}
}
function getXsrfToken(): string | null {
try {
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;
} catch (error) {
console.error(
"[API Client] Failed to read XSRF token from cookies:",
error,
);
return null;
}
}
function processQueue(error: Error | null, token: string | null = null): void {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
}
async function refreshAuthToken(client: AxiosInstance): Promise<string> {
console.log("[API Client] Refreshing expired JWT token...");
try {
const response = await client.post(
"/api/v1/auth/refresh",
{},
{
// Don't retry refresh requests to avoid infinite loops
headers: { "X-Skip-Auth-Refresh": "true" },
},
);
const newToken = response.data?.session?.access_token;
if (!newToken) {
throw new Error("No access token in refresh response");
}
setJwtTokenInStorage(newToken);
console.log("[API Client] ✅ Token refreshed successfully");
return newToken;
} catch (error) {
console.error("[API Client] ❌ Token refresh failed:", error);
clearJwtTokenFromStorage();
// Redirect to login
if (window.location.pathname !== "/login") {
console.log("[API Client] Redirecting to login page...");
window.location.href = "/login";
}
throw error;
}
}
/** Auth headers for raw fetch() calls (SSE streams, etc.). */
export function getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const jwt = getJwtTokenFromStorage();
if (jwt) {
headers["Authorization"] = `Bearer ${jwt}`;
}
const xsrf = getXsrfToken();
if (xsrf) {
headers["X-XSRF-TOKEN"] = xsrf;
}
return headers;
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const authHeaders = getAuthHeaders();
for (const [key, value] of Object.entries(authHeaders)) {
if (!config.headers[key]) {
config.headers[key] = value;
}
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// Install response interceptor to handle 401 and auto-refresh token
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Skip refresh for auth endpoints or if explicitly disabled
// Exception: /auth/me should trigger refresh (used by getSession)
if (
!originalRequest ||
(originalRequest.url?.includes("/api/v1/auth/") &&
!originalRequest.url?.includes("/api/v1/auth/me")) ||
originalRequest.headers?.["X-Skip-Auth-Refresh"] ||
originalRequest._retry
) {
return Promise.reject(error);
}
// Handle 401 errors by attempting token refresh
if (error.response?.status === 401 && getJwtTokenFromStorage()) {
console.warn(
"[API Client] Received 401 error, attempting token refresh...",
);
if (isRefreshing) {
// Already refreshing - queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAuthToken(client);
processQueue(null, newToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);
}
@@ -0,0 +1,63 @@
import apiClient from "@app/services/apiClient";
export interface DatabaseBackupFile {
fileName: string;
filePath?: string;
formattedCreationDate?: string;
formattedFileSize?: string;
creationDate?: string;
fileSize?: number;
}
export interface DatabaseData {
backupFiles: DatabaseBackupFile[];
databaseVersion: string;
versionUnknown: boolean;
}
const databaseManagementService = {
async getDatabaseData(): Promise<DatabaseData> {
const response = await apiClient.get<DatabaseData>(
"/api/v1/proprietary/ui-data/database",
{
suppressErrorToast: true,
},
);
return response.data;
},
async createBackup(): Promise<void> {
await apiClient.get("/api/v1/database/createDatabaseBackup");
},
async importFromFileName(fileName: string): Promise<void> {
await apiClient.get(
`/api/v1/database/import-database-file/${encodeURIComponent(fileName)}`,
);
},
async uploadAndImport(file: File): Promise<void> {
const formData = new FormData();
formData.append("fileInput", file);
await apiClient.post("/api/v1/database/import-database", formData);
},
async deleteBackup(fileName: string): Promise<void> {
await apiClient.get(
`/api/v1/database/delete/${encodeURIComponent(fileName)}`,
);
},
async downloadBackup(fileName: string): Promise<Blob> {
const response = await apiClient.get(
`/api/v1/database/download/${encodeURIComponent(fileName)}`,
{
responseType: "blob",
},
);
return response.data;
},
};
export default databaseManagementService;
@@ -0,0 +1,574 @@
import apiClient from "@app/services/apiClient";
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
import { getCheckoutMode } from "@app/utils/protocolDetection";
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
import type { LicenseInfo, PlanFeature } from "@app/types/license";
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: readonly string[];
isContactOnly?: boolean;
seatPrice?: number; // Per-seat price for enterprise plans
requiresSeats?: boolean; // Flag indicating seat selection is needed
lookupKey: string; // Stripe lookup key for this plan
}
export interface PlanTierGroup {
tier: "free" | "server" | "enterprise";
name: string;
monthly: PlanTier | null;
yearly: PlanTier | null;
features: PlanFeature[];
highlights: readonly string[];
popular?: boolean;
}
export interface PlansResponse {
plans: PlanTier[];
}
export interface CheckoutSessionRequest {
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
installation_id?: string; // Installation ID from backend (MAC-based fingerprint)
current_license_key?: string; // Current license key for upgrades
requires_seats?: boolean; // Whether to add adjustable seat pricing
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
email?: string; // Customer email for checkout pre-fill
successUrl?: string;
cancelUrl?: string;
}
export interface CheckoutSessionResponse {
clientSecret: string;
sessionId: string;
url?: string; // URL for hosted checkout (when not using HTTPS)
}
export interface BillingPortalResponse {
url: string;
}
export interface InstallationIdResponse {
installationId: string;
}
export interface LicenseKeyResponse {
status: "ready" | "pending";
license_key?: string;
email?: string;
plan?: string;
}
export type { LicenseInfo, PlanFeature };
export interface LicenseSaveResponse {
success: boolean;
licenseType?: string;
filename?: string;
filePath?: string;
enabled?: boolean;
maxUsers?: number;
message?: string;
error?: string;
}
// Currency symbol mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
gbp: "£",
usd: "$",
eur: "€",
cny: "¥",
inr: "₹",
brl: "R$",
idr: "Rp",
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
// Self-hosted plan lookup keys
const SELF_HOSTED_LOOKUP_KEYS = [
"selfhosted:server:monthly",
"selfhosted:server:yearly",
"selfhosted:enterpriseseat:monthly",
"selfhosted:enterpriseseat:yearly",
];
const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = "usd"): Promise<PlansResponse> {
try {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error(
"Supabase is not configured. Please use static plans instead.",
);
}
// Fetch all self-hosted prices from Stripe
const { data, error } = await supabase.functions.invoke<{
prices: Record<
string,
{
unit_amount: number;
currency: string;
lookup_key: string;
}
>;
missing: string[];
}>("stripe-price-lookup", {
body: {
lookup_keys: SELF_HOSTED_LOOKUP_KEYS,
currency,
},
});
if (error) {
throw new Error(`Failed to fetch plans: ${error.message}`);
}
if (!data || !data.prices) {
throw new Error("No pricing data returned");
}
// Log missing prices for debugging
if (data.missing && data.missing.length > 0) {
console.warn(
"Missing Stripe prices for lookup keys:",
data.missing,
"in currency:",
currency,
);
}
// Build price map for easy access
const priceMap = new Map<
string,
{ unit_amount: number; currency: string }
>();
for (const [lookupKey, priceData] of Object.entries(data.prices)) {
priceMap.set(lookupKey, {
unit_amount: priceData.unit_amount,
currency: priceData.currency,
});
}
const currencySymbol = getCurrencySymbol(currency);
// Helper to get price info
const getPriceInfo = (lookupKey: string, fallback: number = 0) => {
const priceData = priceMap.get(lookupKey);
return priceData ? priceData.unit_amount / 100 : fallback;
};
// Build plan tiers
const plans: PlanTier[] = [
{
id: "selfhosted:server:monthly",
lookupKey: "selfhosted:server:monthly",
name: "Server - Monthly",
price: getPriceInfo("selfhosted:server:monthly"),
currency: currencySymbol,
period: "/month",
popular: false,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
},
{
id: "selfhosted:server:yearly",
lookupKey: "selfhosted:server:yearly",
name: "Server - Yearly",
price: getPriceInfo("selfhosted:server:yearly"),
currency: currencySymbol,
period: "/year",
popular: true,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY,
},
{
id: "selfhosted:enterprise:monthly",
lookupKey: "selfhosted:server:monthly",
name: "Enterprise - Monthly",
price: getPriceInfo("selfhosted:server:monthly"),
seatPrice: getPriceInfo("selfhosted:enterpriseseat:monthly"),
currency: currencySymbol,
period: "/month",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
},
{
id: "selfhosted:enterprise:yearly",
lookupKey: "selfhosted:server:yearly",
name: "Enterprise - Yearly",
price: getPriceInfo("selfhosted:server:yearly"),
seatPrice: getPriceInfo("selfhosted:enterpriseseat:yearly"),
currency: currencySymbol,
period: "/year",
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY,
},
];
// Filter out plans with missing prices (price === 0 means Stripe price not found)
const validPlans = plans.filter((plan) => plan.price > 0);
if (validPlans.length < plans.length) {
const missingPlans = plans
.filter((plan) => plan.price === 0)
.map((p) => p.id);
console.warn("Filtered out plans with missing prices:", missingPlans);
}
// Add Free plan (static definition)
const freePlan: PlanTier = {
id: "free",
lookupKey: "free",
name: "Free",
price: 0,
currency: currencySymbol,
period: "",
popular: false,
features: PLAN_FEATURES.FREE,
highlights: PLAN_HIGHLIGHTS.FREE,
};
const allPlans = [freePlan, ...validPlans];
return {
plans: allPlans,
};
} catch (error) {
console.error("Error fetching plans:", error);
throw error;
}
},
/**
* Group plans by tier for display (Free, Server, Enterprise)
*/
groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] {
const groups: PlanTierGroup[] = [];
// Free tier
const freePlan = plans.find((p) => p.id === "free");
if (freePlan) {
groups.push({
tier: "free",
name: "Free",
monthly: freePlan,
yearly: null,
features: freePlan.features,
highlights: freePlan.highlights,
popular: false,
});
}
// Server tier
const serverMonthly = plans.find(
(p) => p.lookupKey === "selfhosted:server:monthly",
);
const serverYearly = plans.find(
(p) => p.lookupKey === "selfhosted:server:yearly",
);
if (serverMonthly || serverYearly) {
groups.push({
tier: "server",
name: "Server",
monthly: serverMonthly || null,
yearly: serverYearly || null,
features: (serverMonthly || serverYearly)!.features,
highlights: (serverMonthly || serverYearly)!.highlights,
popular: serverYearly?.popular || serverMonthly?.popular || false,
});
}
// Enterprise tier (uses server pricing + seats)
const enterpriseMonthly = plans.find(
(p) => p.id === "selfhosted:enterprise:monthly",
);
const enterpriseYearly = plans.find(
(p) => p.id === "selfhosted:enterprise:yearly",
);
if (enterpriseMonthly || enterpriseYearly) {
groups.push({
tier: "enterprise",
name: "Enterprise",
monthly: enterpriseMonthly || null,
yearly: enterpriseYearly || null,
features: (enterpriseMonthly || enterpriseYearly)!.features,
highlights: (enterpriseMonthly || enterpriseYearly)!.highlights,
popular: false,
});
}
return groups;
},
/**
* Create a Stripe checkout session for upgrading
*/
async createCheckoutSession(
request: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error("Supabase is not configured. Checkout is not available.");
}
// Detect if HTTPS is available to determine checkout mode
const checkoutMode = getCheckoutMode();
const baseUrl = window.location.origin;
const settingsUrl = `${baseUrl}/settings/adminPlan`;
const { data, error } = await supabase.functions.invoke("create-checkout", {
body: {
self_hosted: true,
lookup_key: request.lookup_key,
installation_id: request.installation_id,
current_license_key: request.current_license_key,
requires_seats: request.requires_seats,
seat_count: request.seat_count || 1,
email: request.email,
callback_base_url: baseUrl,
ui_mode: checkoutMode,
// For hosted checkout, provide success/cancel URLs
success_url:
checkoutMode === "hosted"
? `${settingsUrl}?session_id={CHECKOUT_SESSION_ID}&payment_status=success`
: undefined,
cancel_url:
checkoutMode === "hosted"
? `${settingsUrl}?payment_status=canceled`
: undefined,
},
});
if (error) {
throw new Error(`Failed to create checkout session: ${error.message}`);
}
return data as CheckoutSessionResponse;
},
/**
* Create a Stripe billing portal session for managing subscription
* Uses license key for self-hosted authentication
*/
async createBillingPortalSession(
returnUrl: string,
licenseKey: string,
): Promise<BillingPortalResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error(
"Supabase is not configured. Billing portal is not available.",
);
}
const { data, error } = await supabase.functions.invoke("manage-billing", {
body: {
return_url: returnUrl,
license_key: licenseKey,
self_hosted: true, // Explicitly indicate self-hosted mode
},
});
if (error) {
throw new Error(
`Failed to create billing portal session: ${error.message}`,
);
}
return data as BillingPortalResponse;
},
/**
* Get the installation ID from the backend (MAC-based fingerprint)
*/
async getInstallationId(): Promise<string> {
try {
const response = await apiClient.get("/api/v1/admin/installation-id");
const data: InstallationIdResponse = await response.data;
return data.installationId;
} catch (error) {
console.error("Error fetching installation ID:", error);
throw error;
}
},
/**
* Check if license key is ready for the given installation ID
*/
async checkLicenseKey(installationId: string): Promise<LicenseKeyResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error(
"Supabase is not configured. License key lookup is not available.",
);
}
const { data, error } = await supabase.functions.invoke("get-license-key", {
body: {
installation_id: installationId,
},
});
if (error) {
throw new Error(`Failed to check license key: ${error.message}`);
}
return data as LicenseKeyResponse;
},
/**
* Save license key to backend
*/
async saveLicenseKey(licenseKey: string): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post("/api/v1/admin/license-key", {
licenseKey: licenseKey,
});
return response.data;
} catch (error) {
console.error("Error saving license key:", error);
throw error;
}
},
/**
* Upload license certificate file for offline activation
* @param file - The .lic or .cert file to upload
* @returns Promise with upload result
*/
async saveLicenseFile(file: File): Promise<LicenseSaveResponse> {
try {
const formData = new FormData();
formData.append("file", file);
const response = await apiClient.post(
"/api/v1/admin/license-file",
formData,
);
return response.data;
} catch (error) {
console.error("Error uploading license file:", error);
throw error;
}
},
/**
* Get current license information from backend
*/
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get("/api/v1/admin/license-info", {
suppressErrorToast: true,
});
return response.data;
} catch (error) {
console.error("Error fetching license info:", error);
throw error;
}
},
/**
* Resync the current license with Keygen
* Re-validates the existing license key and updates local settings
*/
async resyncLicense(): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post("/api/v1/admin/license/resync");
return response.data;
} catch (error) {
console.error("Error resyncing license:", error);
throw error;
}
},
/**
* Update enterprise seat count
* Creates a Stripe billing portal session for confirming seat changes
* @param newSeatCount - New number of seats
* @param licenseKey - Current license key for authentication
* @returns Billing portal URL for confirming the change
*/
async updateEnterpriseSeats(
newSeatCount: number,
licenseKey: string,
): Promise<string> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error(
"Supabase is not configured. Seat updates are not available.",
);
}
const baseUrl = window.location.origin;
const returnUrl = `${baseUrl}/settings/adminPlan?seats_updated=true`;
const { data, error } = await supabase.functions.invoke("manage-billing", {
body: {
return_url: returnUrl,
license_key: licenseKey,
self_hosted: true,
new_seat_count: newSeatCount,
},
});
if (error) {
throw new Error(`Failed to update seat count: ${error.message}`);
}
if (!data || !data.url) {
throw new Error("No billing portal URL returned");
}
return data.url;
},
};
/**
* Map license type to plan tier
* @param licenseInfo - Current license information
* @returns Plan tier: 'free' | 'server' | 'enterprise'
*/
export const mapLicenseToTier = (
licenseInfo: LicenseInfo | null,
): "free" | "server" | "enterprise" | null => {
if (!licenseInfo) return null;
// No license or NORMAL type = Free tier
if (licenseInfo.licenseType === "NORMAL" || !licenseInfo.enabled) {
return "free";
}
// SERVER type (unlimited users) = Server tier
if (licenseInfo.licenseType === "SERVER") {
return "server";
}
// ENTERPRISE type (with seats) = Enterprise tier
if (licenseInfo.licenseType === "ENTERPRISE" && licenseInfo.maxUsers > 0) {
return "enterprise";
}
// Default fallback
return "free";
};
export default licenseService;
@@ -0,0 +1,174 @@
import apiClient from "@app/services/apiClient";
import { fileStorage } from "@app/services/fileStorage";
import type { FileId } from "@app/types/file";
import type { StirlingFile } from "@app/types/fileContext";
import type { FileContextActions } from "@app/types/fileContext";
import {
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from "@app/services/shareBundleUtils";
export interface ShareLinkMetadata {
shareToken?: string;
fileId?: number;
fileName?: string;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
createdAt?: string;
expiresAt?: string;
}
export async function fetchShareLinkMetadata(
token: string,
): Promise<ShareLinkMetadata> {
const response = await apiClient.get<ShareLinkMetadata>(
`/api/v1/storage/share-links/${token}/metadata`,
{
suppressErrorToast: true,
skipAuthRedirect: true,
},
);
return response.data || {};
}
export async function downloadShareLink(token: string): Promise<{
blob: Blob;
filename: string;
contentType: string;
}> {
const response = await apiClient.get(`/api/v1/storage/share-links/${token}`, {
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
});
const contentType =
(response.headers &&
(response.headers["content-type"] || response.headers["Content-Type"])) ||
"";
const disposition =
(response.headers &&
(response.headers["content-disposition"] ||
response.headers["Content-Disposition"])) ||
"";
const filename =
parseContentDispositionFilename(disposition) || "shared-file";
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
return { blob, filename, contentType: contentTypeValue };
}
export async function importShareLinkToWorkbench(
token: string,
actions: FileContextActions,
shareMetadata?: ShareLinkMetadata | null,
): Promise<FileId[]> {
const { blob, filename, contentType } = await downloadShareLink(token);
const contentTypeValue = contentType || blob.type;
if (isZipBundle(contentTypeValue, filename)) {
const bundle = await loadShareBundleEntries(blob);
if (bundle) {
const { manifest, rootOrder, sortedEntries, files } = bundle;
const stirlingFiles = await actions.addFilesWithOptions(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
const idMap = new Map<string, FileId>();
for (let i = 0; i < stirlingFiles.length; i += 1) {
idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId);
}
const rootIdMap = new Map<string, FileId>();
for (const rootLogicalId of rootOrder) {
const mappedId = idMap.get(rootLogicalId);
if (mappedId) {
rootIdMap.set(rootLogicalId, mappedId);
}
}
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
};
for (const entry of sortedEntries) {
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const updates = {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
};
actions.updateStirlingFileStub(newId, updates);
await fileStorage.updateFileMetadata(newId, updates);
}
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter(
(entry) => getShareBundleEntryRootId(manifest, entry) === rootId,
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
}
const latestId = idMap.get(latestEntry.logicalId);
if (latestId) {
selectedIds.push(latestId);
}
}
return selectedIds;
}
}
const file = new File([blob], filename, {
type: contentTypeValue || blob.type,
});
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: true,
autoUnzip: false,
skipAutoUnzip: false,
});
const ids = stirlingFiles.map(
(stirlingFile: StirlingFile) => stirlingFile.fileId,
);
if (ids.length > 0) {
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || token,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);
await fileStorage.updateFileMetadata(fileId, sharedUpdates);
}
}
return ids;
}
@@ -0,0 +1,25 @@
// Supabase client. Relocated out of frontend/src/core/services/ during the SaaS<->OSS
// consolidation so the Supabase SDK never reaches the OSS core bundle. Lives in
// :proprietary because licensing/checkout/billing flows in proprietary mode use it; the
// :saas mode bundle picks it up via the existing @app/* path mapping (saas to proprietary
// to core).
import { createClient, SupabaseClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
// Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
// Create client only if configured, otherwise export null
export const supabase: SupabaseClient | null = isSupabaseConfigured
? createClient(supabaseUrl, supabaseAnonKey)
: null;
// Log warning if not configured (for self-hosted installations)
if (!isSupabaseConfigured) {
console.warn(
"Supabase is not configured. Checkout and billing features will be disabled. " +
"Static plan information will be displayed instead.",
);
}
@@ -0,0 +1,123 @@
import apiClient from "@app/services/apiClient";
import type { User } from "@app/services/userManagementService";
export interface Team {
id: number;
name: string;
userCount?: number;
}
export interface TeamMember {
id: number;
username: string;
email?: string;
roleName: string;
enabled: boolean;
team?: {
id: number;
name: string;
};
lastRequest?: Date | null;
}
export interface TeamDetailsResponse {
team: Team;
members: TeamMember[];
availableUsers: TeamMember[];
}
export interface TeamDetailsUIResponse {
team: Team;
teamUsers: User[];
availableUsers: User[];
userLastRequest?: Record<string, number>;
}
/**
* Team Management Service
* Provides functions to interact with team-related backend APIs
*/
export const teamService = {
/**
* Get all teams with user counts
*/
async getTeams(): Promise<Team[]> {
const response = await apiClient.get<{ teamsWithCounts: Team[] }>(
"/api/v1/proprietary/ui-data/teams",
);
return response.data.teamsWithCounts;
},
/**
* Get team details including members
*/
async getTeamDetails(teamId: number): Promise<TeamDetailsUIResponse> {
const response = await apiClient.get<TeamDetailsUIResponse>(
`/api/v1/proprietary/ui-data/teams/${teamId}`,
);
return response.data;
},
/**
* Create a new team
*/
async createTeam(name: string): Promise<void> {
const formData = new FormData();
formData.append("name", name);
await apiClient.post("/api/v1/team/create", formData, {
suppressErrorToast: true,
});
},
/**
* Rename an existing team
*/
async renameTeam(teamId: number, newName: string): Promise<void> {
const formData = new FormData();
formData.append("teamId", teamId.toString());
formData.append("newName", newName);
await apiClient.post("/api/v1/team/rename", formData, {
suppressErrorToast: true,
});
},
/**
* Delete a team (only if it has no members)
*/
async deleteTeam(teamId: number): Promise<void> {
const formData = new FormData();
formData.append("teamId", teamId.toString());
await apiClient.post("/api/v1/team/delete", formData, {
suppressErrorToast: true,
});
},
/**
* Add a user to a team
*/
async addUserToTeam(teamId: number, userId: number): Promise<void> {
const formData = new FormData();
formData.append("teamId", teamId.toString());
formData.append("userId", userId.toString());
await apiClient.post("/api/v1/team/addUser", formData, {
suppressErrorToast: true,
});
},
/**
* Move a user to a specific team (used when "removing" from a team - moves to Default)
*/
async moveUserToTeam(
username: string,
currentRole: string,
teamId: number,
): Promise<void> {
const formData = new FormData();
formData.append("username", username);
formData.append("role", currentRole);
formData.append("teamId", teamId.toString());
await apiClient.post("/api/v1/user/admin/changeRole", formData, {
suppressErrorToast: true,
});
},
};
@@ -0,0 +1,339 @@
import apiClient from "@app/services/apiClient";
export interface User {
id: number;
username: string;
email?: string;
roleName: string; // Translation key like "adminUserSettings.admin"
rolesAsString?: string; // Actual role ID like "ROLE_ADMIN"
enabled: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
team?: {
id: number;
name: string;
};
createdAt?: string;
updatedAt?: string;
// Enriched client-side fields
isActive?: boolean;
lastRequest?: number; // timestamp in milliseconds
mfaEnabled?: boolean; // whether MFA is enabled for the user
}
export interface AdminSettingsData {
users: User[];
userSessions: Record<string, boolean>;
userLastRequest: Record<string, number>; // username -> timestamp in milliseconds
totalUsers: number;
activeUsers: number;
disabledUsers: number;
currentUsername?: string;
roleDetails?: Record<string, string>;
teams?: unknown[];
maxPaidUsers?: number;
// License information
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
mailEnabled: boolean;
userSettings?: Record<string, unknown>;
lockedUsers?: string[];
}
export interface CreateUserRequest {
username: string;
password?: string;
role: string;
teamId?: number;
authType: "WEB" | "OAUTH2" | "SAML2";
forceChange?: boolean;
forceMFA?: boolean;
}
export interface UpdateUserRoleRequest {
username: string;
role: string;
teamId?: number;
}
export interface InviteUsersRequest {
emails: string; // Comma-separated email addresses
role: string;
teamId?: number;
}
export interface InviteUsersResponse {
successCount: number;
failureCount: number;
message?: string;
errors?: string;
error?: string;
}
export interface InviteLinkRequest {
email?: string;
role: string;
teamId?: number;
expiryHours?: number;
sendEmail?: boolean;
frontendBaseUrl?: string;
}
export interface InviteLinkResponse {
token: string;
inviteUrl: string;
email: string;
expiresAt: string;
expiryHours: number;
emailSent?: boolean;
emailError?: string;
error?: string;
}
export interface InviteToken {
id: number;
email: string;
role: string;
teamId?: number;
createdBy: string;
createdAt: string;
expiresAt: string;
}
export interface ChangeUserPasswordRequest {
username: string;
newPassword?: string;
generateRandom?: boolean;
sendEmail?: boolean;
includePassword?: boolean;
forcePasswordChange?: boolean;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
*/
export const userManagementService = {
/**
* Get all users with session data (admin only)
*/
async getUsers(): Promise<AdminSettingsData> {
const response = await apiClient.get<AdminSettingsData>(
"/api/v1/proprietary/ui-data/admin-settings",
);
return response.data;
},
/**
* Get users without a team
*/
async getUsersWithoutTeam(): Promise<User[]> {
const response = await apiClient.get<User[]>("/api/v1/users/without-team");
return response.data;
},
/**
* Create a new user (admin only)
*/
async createUser(data: CreateUserRequest): Promise<void> {
const formData = new FormData();
formData.append("username", data.username);
if (data.password) {
formData.append("password", data.password);
}
formData.append("role", data.role);
if (data.teamId) {
formData.append("teamId", data.teamId.toString());
}
formData.append("authType", data.authType);
if (data.forceChange !== undefined) {
formData.append("forceChange", data.forceChange.toString());
}
if (data.forceMFA !== undefined) {
formData.append("forceMFA", data.forceMFA.toString());
}
await apiClient.post("/api/v1/user/admin/saveUser", formData, {
suppressErrorToast: true, // Component will handle error display
});
},
/**
* Update user role and/or team (admin only)
*/
async updateUserRole(data: UpdateUserRoleRequest): Promise<void> {
const formData = new FormData();
formData.append("username", data.username);
formData.append("role", data.role);
if (data.teamId) {
formData.append("teamId", data.teamId.toString());
}
await apiClient.post("/api/v1/user/admin/changeRole", formData, {
suppressErrorToast: true,
});
},
/**
* Enable or disable a user (admin only)
*/
async toggleUserEnabled(username: string, enabled: boolean): Promise<void> {
const formData = new FormData();
formData.append("enabled", enabled.toString());
await apiClient.post(
`/api/v1/user/admin/changeUserEnabled/${username}`,
formData,
{
suppressErrorToast: true,
},
);
},
/**
* Delete a user (admin only)
*/
async deleteUser(username: string): Promise<void> {
await apiClient.post(`/api/v1/user/admin/deleteUser/${username}`, null, {
suppressErrorToast: true,
});
},
/**
* Invite users via email (admin only)
* Sends comma-separated email addresses, creates accounts with random passwords,
* and sends invitation emails
*/
async inviteUsers(data: InviteUsersRequest): Promise<InviteUsersResponse> {
const formData = new FormData();
formData.append("emails", data.emails);
formData.append("role", data.role);
if (data.teamId) {
formData.append("teamId", data.teamId.toString());
}
const response = await apiClient.post<InviteUsersResponse>(
"/api/v1/user/admin/inviteUsers",
formData,
{
suppressErrorToast: true, // Component will handle error display
},
);
return response.data;
},
/**
* Generate an invite link (admin only)
*/
async generateInviteLink(
data: InviteLinkRequest,
): Promise<InviteLinkResponse> {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append("email", data.email);
}
formData.append("role", data.role);
if (data.teamId) {
formData.append("teamId", data.teamId.toString());
}
if (data.expiryHours) {
formData.append("expiryHours", data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append("sendEmail", data.sendEmail.toString());
}
if (data.frontendBaseUrl) {
formData.append("frontendBaseUrl", data.frontendBaseUrl);
}
const response = await apiClient.post<InviteLinkResponse>(
"/api/v1/invite/generate",
formData,
{
suppressErrorToast: true,
},
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>(
"/api/v1/invite/list",
);
return response.data.invites;
},
/**
* Revoke an invite link (admin only)
*/
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
});
},
/**
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>(
"/api/v1/invite/cleanup",
);
return response.data;
},
/**
* Change another user's password (admin only)
*/
async changeUserPassword(data: ChangeUserPasswordRequest): Promise<void> {
const formData = new FormData();
formData.append("username", data.username);
if (data.newPassword) {
formData.append("newPassword", data.newPassword);
}
if (data.generateRandom !== undefined) {
formData.append("generateRandom", data.generateRandom.toString());
}
if (data.sendEmail !== undefined) {
formData.append("sendEmail", data.sendEmail.toString());
}
if (data.includePassword !== undefined) {
formData.append("includePassword", data.includePassword.toString());
}
if (data.forcePasswordChange !== undefined) {
formData.append(
"forcePasswordChange",
data.forcePasswordChange.toString(),
);
}
await apiClient.post("/api/v1/user/admin/changePasswordForUser", formData, {
suppressErrorToast: true, // Component will handle error display
});
},
/**
* Unlock a locked user account (admin only)
*/
async unlockUser(username: string): Promise<void> {
await apiClient.post(`/api/v1/user/admin/unlockUser/${username}`, null, {
suppressErrorToast: true,
});
},
/**
* Disable MFA for a user (admin only)
*/
async disableMfaByAdmin(username: string): Promise<void> {
await apiClient.post(
`/api/v1/auth/mfa/disable/admin/${encodeURIComponent(username)}`,
undefined,
);
},
};
@@ -0,0 +1,137 @@
import api from "@app/services/apiClient";
export interface ParticipantResponse {
id: number;
userId?: number;
email: string;
name: string;
status: "PENDING" | "NOTIFIED" | "VIEWED" | "SIGNED" | "DECLINED";
// Null for participant-facing endpoints (`/api/v1/workflow/participant/...`); the owner-facing
// `/api/v1/security/cert-sign/sessions/...` endpoints still populate it for share-link
// distribution. Never used to look up other participants — see GHSA-qgg6-mxw4-xg62.
shareToken: string | null;
accessRole: "EDITOR" | "COMMENTER" | "VIEWER";
expiresAt?: string;
lastUpdated: string;
hasCompleted: boolean;
isExpired: boolean;
}
export interface WorkflowSessionResponse {
sessionId: string;
ownerId: number;
ownerUsername: string;
workflowType: "SIGNING" | "REVIEW" | "APPROVAL";
documentName: string;
ownerEmail?: string;
message?: string;
dueDate?: string;
status: "IN_PROGRESS" | "COMPLETED" | "CANCELLED";
finalized: boolean;
createdAt: string;
updatedAt: string;
participants: ParticipantResponse[];
hasProcessedFile: boolean;
originalFileId?: number;
processedFileId?: number;
}
export interface SignatureSubmissionRequest {
participantToken: string;
certType?: string;
password?: string;
p12File?: File;
jksFile?: File;
showSignature?: boolean;
pageNumber?: number;
location?: string;
reason?: string;
showLogo?: boolean;
wetSignatureData?: string;
}
/**
* Service for managing workflow sessions
*/
class WorkflowService {
/**
* Get session details by participant token (no authentication required)
*/
async getSessionByToken(token: string): Promise<WorkflowSessionResponse> {
const response = await api.get("/api/v1/workflow/participant/session", {
params: { token },
});
return response.data;
}
/**
* Get participant details by token
*/
async getParticipantDetails(token: string): Promise<ParticipantResponse> {
const response = await api.get("/api/v1/workflow/participant/details", {
params: { token },
});
return response.data;
}
/**
* Submit signature as a participant
*/
async submitSignature(
request: SignatureSubmissionRequest,
): Promise<ParticipantResponse> {
const formData = new FormData();
formData.append("participantToken", request.participantToken);
if (request.certType) formData.append("certType", request.certType);
if (request.password) formData.append("password", request.password);
if (request.p12File) formData.append("p12File", request.p12File);
if (request.jksFile) formData.append("jksFile", request.jksFile);
if (request.showSignature !== undefined)
formData.append("showSignature", request.showSignature.toString());
if (request.pageNumber)
formData.append("pageNumber", request.pageNumber.toString());
if (request.location) formData.append("location", request.location);
if (request.reason) formData.append("reason", request.reason);
if (request.showLogo !== undefined)
formData.append("showLogo", request.showLogo.toString());
if (request.wetSignatureData)
formData.append("wetSignatureData", request.wetSignatureData);
const response = await api.post(
"/api/v1/workflow/participant/submit-signature",
formData,
);
return response.data;
}
/**
* Decline participation
*/
async declineParticipation(
token: string,
reason?: string,
): Promise<ParticipantResponse> {
const response = await api.post(
"/api/v1/workflow/participant/decline",
null,
{
params: { token, reason },
},
);
return response.data;
}
/**
* Download document as participant
*/
async getParticipantDocument(token: string): Promise<Blob> {
const response = await api.get("/api/v1/workflow/participant/document", {
params: { token },
responseType: "blob",
});
return response.data;
}
}
export default new WorkflowService();