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:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
@@ -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');
}
});
});
+197
View File
@@ -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;
},
};
+43
View File
@@ -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();
};