mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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,32 @@
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
interface DeleteAccountOptions {
|
||||
notifyUser?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteUserResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
deleted_supabase_id?: string;
|
||||
stripe_redaction_job_id?: string | null;
|
||||
}
|
||||
|
||||
export async function deleteCurrentAccount(
|
||||
options?: DeleteAccountOptions,
|
||||
): Promise<void> {
|
||||
const { data, error } = await supabase.functions.invoke<DeleteUserResponse>(
|
||||
"delete-user",
|
||||
{
|
||||
body: {
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !data?.success) {
|
||||
const serverMessage = data?.error;
|
||||
const errorMessage =
|
||||
serverMessage || error?.message || "Failed to delete account";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Session, AuthError } from "@supabase/supabase-js";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
// Mock supabase
|
||||
vi.mock("@app/auth/supabase", () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("apiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset modules to get fresh instance of apiClient
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("should add JWT token to request headers when session exists", async () => {
|
||||
const mockToken = "test-jwt-token-12345";
|
||||
const mockSession = {
|
||||
access_token: mockToken,
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
// Mock getSession to return a session with token
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: mockSession },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify the Authorization header is set correctly
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${mockToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the request was made with the token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle requests when no session exists", async () => {
|
||||
// Mock getSession to return no session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: null },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Create a mock adapter to intercept the request
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Verify no Authorization header is set
|
||||
expect(config.headers.Authorization).toBeUndefined();
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request
|
||||
await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the request was made without a token
|
||||
expect(mockAdapter).toHaveBeenCalled();
|
||||
expect(supabase.auth.getSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should refresh token on 401 response", async () => {
|
||||
const oldToken = "old-token";
|
||||
const newToken = "new-refreshed-token";
|
||||
|
||||
const oldSession = {
|
||||
access_token: oldToken,
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
const newSession = {
|
||||
access_token: newToken,
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
token_type: "bearer",
|
||||
user: { id: "user-123" },
|
||||
} as unknown as Session;
|
||||
|
||||
// Mock initial session for first request
|
||||
let getSessionCallCount = 0;
|
||||
vi.mocked(supabase.auth.getSession).mockImplementation(async () => {
|
||||
getSessionCallCount++;
|
||||
// First call returns old session, subsequent calls return new session
|
||||
if (getSessionCallCount === 1) {
|
||||
return { data: { session: oldSession }, error: null };
|
||||
}
|
||||
return { data: { session: newSession }, error: null };
|
||||
});
|
||||
|
||||
// Mock refresh to return new session
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: newSession },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
let requestCount = 0;
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
requestCount++;
|
||||
|
||||
// First request returns 401
|
||||
if (requestCount === 1) {
|
||||
// Verify first request has old token
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${oldToken}`);
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401, data: { error: "Unauthorized" } },
|
||||
config,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Second request (after refresh) should have new token
|
||||
// The interceptor will call getSession again, which now returns the new session
|
||||
expect(config.headers.Authorization).toBe(`Bearer ${newToken}`);
|
||||
return Promise.resolve({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401 and retry
|
||||
const response = await apiClient.get("/api/v1/test");
|
||||
|
||||
// Verify the token was refreshed and request retried
|
||||
expect(response.data).toEqual({ success: true });
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
expect(mockAdapter).toHaveBeenCalledTimes(2);
|
||||
expect(getSessionCallCount).toBe(3); // Called for initial request, for checking if refresh is possible, and for retry
|
||||
});
|
||||
|
||||
it("should handle refresh token failure", async () => {
|
||||
const oldToken = "old-token";
|
||||
|
||||
const oldSession = {
|
||||
access_token: oldToken,
|
||||
user: { id: "user-123" },
|
||||
};
|
||||
|
||||
// Mock initial session
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession as unknown as Session },
|
||||
error: null,
|
||||
});
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession as unknown as Session },
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Mock refresh to fail
|
||||
vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
|
||||
data: { user: null, session: null },
|
||||
error: {
|
||||
name: "AuthError",
|
||||
message: "Refresh failed",
|
||||
status: 400,
|
||||
code: "auth_error",
|
||||
} as unknown as AuthError,
|
||||
});
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import("@app/services/apiClient");
|
||||
|
||||
// Mock window.location for redirect test
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href: "" },
|
||||
});
|
||||
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Always return 401 to trigger refresh
|
||||
const error = Object.assign(new Error("Unauthorized"), {
|
||||
response: { status: 401, data: { error: "Unauthorized" } },
|
||||
config,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// Replace the adapter
|
||||
apiClient.defaults.adapter = mockAdapter;
|
||||
|
||||
// Make a test request that will trigger 401
|
||||
try {
|
||||
await apiClient.get("/api/v1/test");
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (_) {
|
||||
// Verify refresh was attempted
|
||||
expect(supabase.auth.refreshSession).toHaveBeenCalled();
|
||||
// Verify redirect to login
|
||||
expect(window.location.href).toBe("/login");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import axios from "axios";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
|
||||
// Function to set the global credit update callback
|
||||
export const setGlobalCreditUpdateCallback = (
|
||||
callback: (credits: number) => void,
|
||||
) => {
|
||||
globalCreditUpdateCallback = callback;
|
||||
};
|
||||
|
||||
// Helper: decode base64url JWT payload safely
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(
|
||||
base64.length + ((4 - (base64.length % 4)) % 4),
|
||||
"=",
|
||||
);
|
||||
const json =
|
||||
typeof atob !== "undefined"
|
||||
? atob(padded)
|
||||
: Buffer.from(padded, "base64").toString("binary");
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.warn("[API Client] Failed to decode JWT payload:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
responseType: "json",
|
||||
});
|
||||
|
||||
const LOW_CREDIT_THRESHOLD = 10;
|
||||
function notifyLowCredits(credits: number) {
|
||||
const title = "Credit balance low";
|
||||
const body = `You have ${credits} credits remaining.`;
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title,
|
||||
body,
|
||||
buttonText: "Top up",
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: "bottom-right",
|
||||
});
|
||||
}
|
||||
// Request interceptor to add JWT token to all requests
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
try {
|
||||
// Get the current session from Supabase
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[API Client] Error getting session:", error);
|
||||
}
|
||||
|
||||
// If we have a session with an access token, add it to the Authorization header
|
||||
if (session?.access_token) {
|
||||
config.headers.Authorization = `Bearer ${session.access_token}`;
|
||||
const payload = decodeJwtPayload(session.access_token);
|
||||
const role =
|
||||
(payload?.["role"] as string) ||
|
||||
(payload?.["user_role"] as string) ||
|
||||
undefined;
|
||||
const aud = payload?.["aud"] as string | undefined;
|
||||
const isAnon = role === "anon" || aud === "anon";
|
||||
|
||||
// Debug logs for visibility during integration
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug("[API Client] Added JWT token to request:", config.url);
|
||||
console.debug("[API Client] JWT payload:", payload);
|
||||
console.debug(
|
||||
"[API Client] Token role:",
|
||||
role,
|
||||
"| aud:",
|
||||
aud,
|
||||
"| isAnon:",
|
||||
isAnon,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.debug(
|
||||
"[API Client] No JWT token available for request:",
|
||||
config.url,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[API Client] Error in request interceptor:", error);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// List of endpoints that don't require authentication
|
||||
const publicEndpoints = [
|
||||
"/api/v1/config/app-config",
|
||||
"/api/v1/info/status",
|
||||
"/api/v1/config/public-config",
|
||||
"/api/v1/config/endpoints-enabled",
|
||||
];
|
||||
|
||||
// Response interceptor for handling token refresh and credit updates
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Check for X-Credits-Remaining header and update credits automatically
|
||||
const creditsRemaining = response.headers["x-credits-remaining"];
|
||||
if (creditsRemaining && globalCreditUpdateCallback) {
|
||||
const credits = parseInt(creditsRemaining, 10);
|
||||
if (!isNaN(credits) && credits >= 0) {
|
||||
console.debug(
|
||||
"[API Client] Updating credits from response header:",
|
||||
credits,
|
||||
"for URL:",
|
||||
response.config?.url,
|
||||
);
|
||||
globalCreditUpdateCallback(credits);
|
||||
// Show low-credit toast with top-up button when below threshold
|
||||
if (credits < LOW_CREDIT_THRESHOLD) {
|
||||
notifyLowCredits(credits);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"[API Client] Invalid credits value in response header:",
|
||||
creditsRemaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.config?.url?.includes("/api/v1/credits")) {
|
||||
console.debug(
|
||||
"[API Client] Credits endpoint response headers:",
|
||||
response.headers,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const isPublicEndpoint = publicEndpoints.some((endpoint) =>
|
||||
originalRequest.url?.includes(endpoint),
|
||||
);
|
||||
|
||||
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
!isPublicEndpoint
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Check if we have a session to refresh
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
// Only try to refresh if we actually have a session
|
||||
if (session) {
|
||||
const {
|
||||
data: { session: refreshedSession },
|
||||
error: refreshError,
|
||||
} = await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshError) {
|
||||
console.error("[API Client] Token refresh failed:", refreshError);
|
||||
|
||||
// Only redirect to login for protected endpoints, not public ones
|
||||
const isPublicEndpoint =
|
||||
originalRequest.url?.includes("/api/v1/config/") ||
|
||||
originalRequest.url?.includes("/api/v1/info/");
|
||||
|
||||
if (!isPublicEndpoint) {
|
||||
// Redirect to login only for protected endpoints
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (refreshedSession?.access_token) {
|
||||
// Update the Authorization header with the new token
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${refreshedSession.access_token}`;
|
||||
console.debug("[API Client] Retrying request with refreshed token");
|
||||
|
||||
// Retry the original request with the new token
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} else {
|
||||
// No session exists, only redirect if not already on login page
|
||||
console.debug(
|
||||
"[API Client] No session to refresh, 401 on protected endpoint",
|
||||
);
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[API Client] Error during token refresh:", refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// For public endpoints with 401, just log and continue (don't redirect)
|
||||
if (isPublicEndpoint && error.response?.status === 401) {
|
||||
console.debug(
|
||||
"[API Client] 401 on public endpoint, continuing without auth:",
|
||||
originalRequest.url,
|
||||
);
|
||||
}
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
|
||||
console.error("[API Client] HTTP Error", {
|
||||
status,
|
||||
method,
|
||||
url,
|
||||
error: error.message,
|
||||
data: error.response?.data,
|
||||
});
|
||||
await handleHttpError(error); // Handle error (shows toast unless suppressed)
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Avatar sync service for OAuth provider profile pictures
|
||||
* Downloads, optimizes, and syncs profile pictures from OAuth providers
|
||||
*/
|
||||
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
|
||||
const PROFILE_BUCKET = "profile-pictures";
|
||||
const AVATAR_SIZE = 256; // 256x256 pixels
|
||||
const MAX_AVATAR_SIZE = 500 * 1024; // 500KB max file size after optimization
|
||||
const SYNC_INTERVAL_DAYS = 7; // Resync every 7 days
|
||||
|
||||
// Client-side cache to prevent repeated sync attempts in same browser session
|
||||
const sessionSyncCache = new Map<
|
||||
string,
|
||||
{ timestamp: number; success: boolean }
|
||||
>();
|
||||
|
||||
export interface ProfilePictureMetadata {
|
||||
user_id: string;
|
||||
source: "oauth" | "upload";
|
||||
provider: "google" | "github" | "apple" | "azure" | null;
|
||||
last_synced_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract avatar URL from OAuth provider user metadata
|
||||
* @param user Supabase User object
|
||||
* @returns Avatar URL or null if not available
|
||||
*/
|
||||
export function getProviderAvatarUrl(user: User): string | null {
|
||||
const provider = user.app_metadata?.provider;
|
||||
const metadata = user.user_metadata;
|
||||
|
||||
if (!provider || !metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "google":
|
||||
case "azure":
|
||||
// Google and Azure use 'picture' field
|
||||
return metadata.picture || null;
|
||||
case "github":
|
||||
// GitHub uses 'avatar_url' field
|
||||
return metadata.avatar_url || null;
|
||||
case "apple":
|
||||
// Apple doesn't provide profile pictures via OAuth
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and optimize an avatar image
|
||||
* Resizes to 256x256 and converts to PNG format
|
||||
* @param url Avatar URL from OAuth provider
|
||||
* @returns Optimized image blob
|
||||
*/
|
||||
export async function downloadAndOptimizeAvatar(url: string): Promise<Blob> {
|
||||
try {
|
||||
// 1. Fetch image from provider URL
|
||||
const response = await fetch(url, {
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download avatar: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
// 2. Create image bitmap
|
||||
const img = await createImageBitmap(blob);
|
||||
|
||||
// 3. Create canvas and draw scaled image
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = AVATAR_SIZE;
|
||||
canvas.height = AVATAR_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Failed to get canvas context");
|
||||
}
|
||||
|
||||
// Draw image scaled to fit (maintains aspect ratio, centered)
|
||||
const scale = Math.min(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height);
|
||||
const x = (AVATAR_SIZE - img.width * scale) / 2;
|
||||
const y = (AVATAR_SIZE - img.height * scale) / 2;
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
|
||||
|
||||
// 4. Convert to PNG blob with quality optimization
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(optimizedBlob) => {
|
||||
if (!optimizedBlob) {
|
||||
reject(new Error("Failed to create optimized blob"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (optimizedBlob.size > MAX_AVATAR_SIZE) {
|
||||
console.warn(
|
||||
"[Avatar Sync] Optimized avatar exceeds max size:",
|
||||
optimizedBlob.size,
|
||||
);
|
||||
// Try with lower quality
|
||||
canvas.toBlob(
|
||||
(lowerQualityBlob) => {
|
||||
if (lowerQualityBlob) {
|
||||
resolve(lowerQualityBlob);
|
||||
} else {
|
||||
reject(new Error("Failed to create lower quality blob"));
|
||||
}
|
||||
},
|
||||
"image/png",
|
||||
0.7,
|
||||
);
|
||||
} else {
|
||||
resolve(optimizedBlob);
|
||||
}
|
||||
},
|
||||
"image/png",
|
||||
0.9,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to download and optimize avatar:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload avatar blob to Supabase Storage
|
||||
* @param userId User ID
|
||||
* @param blob Optimized avatar blob
|
||||
*/
|
||||
export async function uploadAvatarToStorage(
|
||||
userId: string,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profilePath = `${userId}/avatar`;
|
||||
|
||||
console.debug("[Avatar Sync] Uploading avatar to storage:", profilePath);
|
||||
|
||||
// Upload to Supabase Storage (overwrites existing file)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from(PROFILE_BUCKET)
|
||||
.upload(profilePath, blob, {
|
||||
upsert: true, // Overwrite existing file
|
||||
contentType: "image/png",
|
||||
cacheControl: "3600", // Cache for 1 hour
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
console.debug("[Avatar Sync] Avatar uploaded successfully");
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to upload avatar to storage:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile picture metadata for a user
|
||||
* @param userId User ID
|
||||
* @returns Metadata or null if not found
|
||||
*/
|
||||
export async function getProfilePictureMetadata(
|
||||
userId: string,
|
||||
): Promise<ProfilePictureMetadata | null> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("profile_picture_metadata")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, that's expected before migration runs
|
||||
if (
|
||||
error.code === "PGRST116" ||
|
||||
error.message?.includes("does not exist")
|
||||
) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Metadata table not found - migration may not be applied yet",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
console.error(
|
||||
"[Avatar Sync] Failed to fetch profile picture metadata:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Unexpected error fetching metadata:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or insert profile picture metadata
|
||||
* @param userId User ID
|
||||
* @param data Partial metadata to update
|
||||
*/
|
||||
export async function updateProfilePictureMetadata(
|
||||
userId: string,
|
||||
data: Partial<
|
||||
Omit<ProfilePictureMetadata, "user_id" | "created_at" | "updated_at">
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { error } = await supabase.from("profile_picture_metadata").upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onConflict: "user_id",
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// If table doesn't exist, log but don't crash
|
||||
if (
|
||||
error.code === "PGRST116" ||
|
||||
error.message?.includes("does not exist")
|
||||
) {
|
||||
console.warn(
|
||||
"[Avatar Sync] Cannot update metadata - table does not exist. Run migration first.",
|
||||
);
|
||||
return; // Don't throw, allow feature to work without metadata tracking
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.debug("[Avatar Sync] Metadata updated successfully");
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to update metadata:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to sync OAuth avatar for a user
|
||||
* Downloads avatar from OAuth provider and uploads to Supabase Storage
|
||||
* Only syncs if:
|
||||
* - User is authenticated via OAuth provider that supports avatars
|
||||
* - User hasn't manually uploaded a picture (source !== 'upload')
|
||||
* - Last sync was more than SYNC_INTERVAL_DAYS ago (or never synced)
|
||||
*
|
||||
* @param user Supabase User object
|
||||
* @returns true if sync was performed, false if skipped
|
||||
*/
|
||||
export async function syncOAuthAvatar(user: User): Promise<boolean> {
|
||||
const cacheKey = user.id;
|
||||
|
||||
try {
|
||||
// 0. Check client-side session cache first (prevent repeated attempts)
|
||||
const cached = sessionSyncCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const minutesSinceLastAttempt =
|
||||
(Date.now() - cached.timestamp) / (1000 * 60);
|
||||
if (minutesSinceLastAttempt < 60) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - already attempted in this session:",
|
||||
{
|
||||
minutesAgo: minutesSinceLastAttempt.toFixed(1),
|
||||
lastSuccess: cached.success,
|
||||
},
|
||||
);
|
||||
return cached.success;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check if user is OAuth authenticated
|
||||
const provider = user.app_metadata?.provider;
|
||||
console.debug("[Avatar Sync] Checking user for sync:", {
|
||||
provider,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
hasUserMetadata: !!user.user_metadata,
|
||||
userMetadataKeys: user.user_metadata
|
||||
? Object.keys(user.user_metadata)
|
||||
: [],
|
||||
});
|
||||
|
||||
if (!provider || !["google", "github", "azure"].includes(provider)) {
|
||||
console.debug(
|
||||
"[Avatar Sync] Skipping sync - not an OAuth provider with avatar support",
|
||||
);
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Get metadata to check if sync is needed
|
||||
const metadata = await getProfilePictureMetadata(user.id);
|
||||
|
||||
// Skip if user has manually uploaded a picture
|
||||
if (metadata?.source === "upload") {
|
||||
console.debug("[Avatar Sync] Skipping sync - user has manual upload");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if synced recently (within SYNC_INTERVAL_DAYS)
|
||||
if (metadata?.last_synced_at) {
|
||||
const lastSync = new Date(metadata.last_synced_at);
|
||||
const daysSinceSync =
|
||||
(Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceSync < SYNC_INTERVAL_DAYS) {
|
||||
console.debug("[Avatar Sync] Skipping sync - synced recently:", {
|
||||
daysSinceSync: daysSinceSync.toFixed(1),
|
||||
threshold: SYNC_INTERVAL_DAYS,
|
||||
});
|
||||
sessionSyncCache.set(cacheKey, {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract provider avatar URL
|
||||
const avatarUrl = getProviderAvatarUrl(user);
|
||||
console.debug("[Avatar Sync] Avatar URL extraction:", {
|
||||
provider,
|
||||
avatarUrl,
|
||||
hasAvatarUrl: !!avatarUrl,
|
||||
});
|
||||
|
||||
if (!avatarUrl) {
|
||||
console.debug("[Avatar Sync] No avatar URL available from provider");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
"[Avatar Sync] Starting sync for provider:",
|
||||
provider,
|
||||
"with URL:",
|
||||
avatarUrl,
|
||||
);
|
||||
|
||||
// 4. Download and optimize avatar
|
||||
const optimizedBlob = await downloadAndOptimizeAvatar(avatarUrl);
|
||||
|
||||
// 5. Upload to Supabase Storage
|
||||
await uploadAvatarToStorage(user.id, optimizedBlob);
|
||||
|
||||
// 6. Update metadata
|
||||
await updateProfilePictureMetadata(user.id, {
|
||||
source: "oauth",
|
||||
provider: provider as ProfilePictureMetadata["provider"],
|
||||
last_synced_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.debug("[Avatar Sync] Sync completed successfully");
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Avatar Sync] Failed to sync OAuth avatar:", error);
|
||||
// Cache the failure to prevent repeated attempts
|
||||
sessionSyncCache.set(cacheKey, { timestamp: Date.now(), success: false });
|
||||
// Don't throw - gracefully degrade to existing picture or initials
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { SavedSignature } from "@app/hooks/tools/sign/useSavedSignatures";
|
||||
|
||||
export type StorageType = "backend" | "localStorage";
|
||||
|
||||
interface SignatureStorageCapabilities {
|
||||
supportsBackend: boolean;
|
||||
storageType: StorageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-specific signature storage service that always uses localStorage.
|
||||
*
|
||||
* In SaaS mode, the proprietary backend signature API is not available
|
||||
* (requires Spring Security JWT, not Supabase JWT), so we skip detection
|
||||
* and force localStorage-only mode to avoid unnecessary 401/403 errors.
|
||||
*/
|
||||
class SignatureStorageService {
|
||||
private capabilities: SignatureStorageCapabilities | null = null;
|
||||
private blobUrls: Set<string> = new Set();
|
||||
private readonly STORAGE_KEY = "stirling:saved-signatures:v1";
|
||||
|
||||
/**
|
||||
* Detect capabilities - in SaaS mode, always returns localStorage
|
||||
*/
|
||||
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
|
||||
if (this.capabilities) {
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
// SaaS mode always uses localStorage (no backend signature API available)
|
||||
console.log(
|
||||
"[SignatureStorage] SaaS mode - using localStorage (backend not available)",
|
||||
);
|
||||
this.capabilities = {
|
||||
supportsBackend: false,
|
||||
storageType: "localStorage",
|
||||
};
|
||||
|
||||
return this.capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current storage type
|
||||
*/
|
||||
async getStorageType(): Promise<StorageType> {
|
||||
const capabilities = await this.detectCapabilities();
|
||||
return capabilities.storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all signatures
|
||||
*/
|
||||
async loadSignatures(): Promise<SavedSignature[]> {
|
||||
// Clean up old blob URLs before loading new ones
|
||||
this.cleanup();
|
||||
|
||||
// Always use localStorage in SaaS mode
|
||||
return this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a signature
|
||||
*/
|
||||
async saveSignature(signature: SavedSignature): Promise<void> {
|
||||
// Force scope to localStorage for SaaS mode
|
||||
signature.scope = "localStorage";
|
||||
this._saveToLocalStorage(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature
|
||||
*/
|
||||
async deleteSignature(id: string): Promise<void> {
|
||||
this._deleteFromLocalStorage(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update signature label
|
||||
*/
|
||||
async updateSignatureLabel(id: string, label: string): Promise<void> {
|
||||
this._updateLabelInLocalStorage(id, label);
|
||||
}
|
||||
|
||||
// LocalStorage methods
|
||||
private _loadFromLocalStorage(): SavedSignature[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const signatures = JSON.parse(raw);
|
||||
// Ensure all localStorage signatures have the correct scope
|
||||
return signatures.map((sig: SavedSignature) => ({
|
||||
...sig,
|
||||
scope: "localStorage" as const,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private _saveToLocalStorage(signature: SavedSignature): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const index = signatures.findIndex((s) => s.id === signature.id);
|
||||
|
||||
if (index >= 0) {
|
||||
signatures[index] = signature;
|
||||
} else {
|
||||
signatures.unshift(signature);
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
|
||||
private _deleteFromLocalStorage(id: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const filtered = signatures.filter((s) => s.id !== id);
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
private _updateLabelInLocalStorage(id: string, label: string): void {
|
||||
const signatures = this._loadFromLocalStorage();
|
||||
const signature = signatures.find((s) => s.id === id);
|
||||
if (signature) {
|
||||
signature.label = label;
|
||||
signature.updatedAt = Date.now();
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate signatures from localStorage to backend
|
||||
* In SaaS mode, this is a no-op since we don't support backend storage
|
||||
*/
|
||||
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
|
||||
console.log("[SignatureStorage] Migration not supported in SaaS mode");
|
||||
return { migrated: 0, failed: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up blob URLs to prevent memory leaks
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.blobUrls.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.blobUrls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const signatureStorageService = new SignatureStorageService();
|
||||
@@ -0,0 +1,289 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
supabaseId?: string | null;
|
||||
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
|
||||
}
|
||||
|
||||
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?: Array<{ id: number; name: string; [key: string]: unknown }>;
|
||||
maxPaidUsers?: number;
|
||||
// License information
|
||||
maxAllowedUsers: number;
|
||||
availableSlots: number;
|
||||
licenseMaxUsers: number;
|
||||
premiumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
password?: string;
|
||||
role: string;
|
||||
teamId?: number;
|
||||
authType: "password" | "SSO";
|
||||
forceChange?: 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
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(
|
||||
user: User,
|
||||
options?: { notifyUser?: boolean },
|
||||
): Promise<void> {
|
||||
if (isSupabaseConfigured && supabase) {
|
||||
if (!user.email) {
|
||||
throw new Error(
|
||||
"Email missing for this user. Please contact support for manual removal.",
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await supabase.functions.invoke("delete-user", {
|
||||
body: {
|
||||
target_email: user.email,
|
||||
notify_user: options?.notifyUser ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Supabase deletion failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* User service for handling user-related API calls
|
||||
*/
|
||||
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
/**
|
||||
* Synchronizes user upgrade from anonymous to authenticated status with the backend.
|
||||
* This should be called after Supabase has successfully upgraded the user.
|
||||
* Only the current user can upgrade their own account - the backend determines
|
||||
* the user from the security context and derives email from SupabaseUser.
|
||||
*
|
||||
* @param authMethod - The authentication method used (e.g., "email", "google", "github", "apple", "azure")
|
||||
* @returns Promise with the synchronization result
|
||||
*/
|
||||
export const synchronizeUserUpgrade = async (
|
||||
authMethod?: string,
|
||||
): Promise<{
|
||||
message: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}> => {
|
||||
const formData = new URLSearchParams();
|
||||
if (authMethod) {
|
||||
formData.append("authMethod", authMethod);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/user-role/promptToAuthUser`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
credentials: "include", // Include cookies for authentication
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Failed to synchronize user upgrade" }));
|
||||
throw new Error(errorData.error || "Failed to synchronize user upgrade");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
Reference in New Issue
Block a user