mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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,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();
|
||||
Reference in New Issue
Block a user