mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Add SaaS frontend code (#5879)
# Description of Changes Adds the code for the SaaS frontend as proprietary code to the OSS repo. This version of the code is adapted from 22/1/2026, which was the last SaaS version based on the 'V2' design. This will move us closer to being able to have the OSS products understand whether the user has a SaaS account, and provide the correct UI in those cases.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
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,237 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
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: any = {
|
||||
access_token: mockToken,
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
// 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: any = {
|
||||
access_token: oldToken,
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
const newSession: any = {
|
||||
access_token: newToken,
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
user: { id: 'user-123' },
|
||||
};
|
||||
|
||||
// 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 as any,
|
||||
} as any);
|
||||
|
||||
// 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: any = new Error('Unauthorized');
|
||||
error.response = {
|
||||
status: 401,
|
||||
data: { error: 'Unauthorized' },
|
||||
};
|
||||
error.config = 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 },
|
||||
error: null,
|
||||
} as any);
|
||||
vi.mocked(supabase.auth.getSession).mockResolvedValue({
|
||||
data: { session: oldSession },
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// 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', __isAuthError: true } as any,
|
||||
} as any);
|
||||
|
||||
// Import apiClient after mocking
|
||||
const { default: apiClient } = await import('@app/services/apiClient');
|
||||
|
||||
// Mock window.location for redirect test
|
||||
delete (window as any).location;
|
||||
window.location = { href: '' } as any;
|
||||
|
||||
const mockAdapter = vi.fn((config) => {
|
||||
// Always return 401 to trigger refresh
|
||||
const error: any = new Error('Unauthorized');
|
||||
error.response = {
|
||||
status: 401,
|
||||
data: { error: 'Unauthorized' },
|
||||
};
|
||||
error.config = 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,197 @@
|
||||
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 || '/', // Use env var or relative path (proxied by Vite in dev)
|
||||
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,338 @@
|
||||
/**
|
||||
* 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,147 @@
|
||||
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,272 @@
|
||||
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?: any[];
|
||||
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
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
} as any
|
||||
);
|
||||
|
||||
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,
|
||||
} as any
|
||||
);
|
||||
|
||||
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,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,43 @@
|
||||
/**
|
||||
* 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