diff --git a/src/platform/auth/workspace/workspaceConstants.ts b/src/platform/auth/workspace/workspaceConstants.ts index cc28d1f47..b56c1644d 100644 --- a/src/platform/auth/workspace/workspaceConstants.ts +++ b/src/platform/auth/workspace/workspaceConstants.ts @@ -1,7 +1,10 @@ export const WORKSPACE_STORAGE_KEYS = { + // sessionStorage keys (cleared on browser close) CURRENT_WORKSPACE: 'Comfy.Workspace.Current', TOKEN: 'Comfy.Workspace.Token', - EXPIRES_AT: 'Comfy.Workspace.ExpiresAt' + EXPIRES_AT: 'Comfy.Workspace.ExpiresAt', + // localStorage key (persists across browser sessions) + LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId' } as const export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 diff --git a/src/platform/navigation/preservedQueryNamespaces.ts b/src/platform/navigation/preservedQueryNamespaces.ts index 541f18869..f8fb0a50e 100644 --- a/src/platform/navigation/preservedQueryNamespaces.ts +++ b/src/platform/navigation/preservedQueryNamespaces.ts @@ -1,3 +1,4 @@ export const PRESERVED_QUERY_NAMESPACES = { - TEMPLATE: 'template' + TEMPLATE: 'template', + INVITE: 'invite' } as const diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts new file mode 100644 index 000000000..4ea581dfb --- /dev/null +++ b/src/platform/workspace/api/workspaceApi.ts @@ -0,0 +1,334 @@ +import type { AxiosResponse } from 'axios' +import axios from 'axios' + +import { t } from '@/i18n' +import { api } from '@/scripts/api' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import type { AuthHeader } from '@/types/authTypes' + +// Types aligned with backend API +export type WorkspaceType = 'personal' | 'team' +export type WorkspaceRole = 'owner' | 'member' + +interface Workspace { + id: string + name: string + type: WorkspaceType +} + +export interface WorkspaceWithRole extends Workspace { + role: WorkspaceRole +} + +// Member type from API +export interface Member { + id: string + name: string + email: string + joined_at: string +} + +interface PaginationInfo { + offset: number + limit: number + total: number +} + +interface ListMembersResponse { + members: Member[] + pagination: PaginationInfo +} + +export interface ListMembersParams { + offset?: number + limit?: number +} + +// Pending invite type from API +export interface PendingInvite { + id: string + email: string + token: string + invited_at: string + expires_at: string +} + +interface ListInvitesResponse { + invites: PendingInvite[] +} + +interface CreateInviteRequest { + email: string +} + +interface AcceptInviteResponse { + workspace_id: string + workspace_name: string +} + +// Billing types (POST /api/billing/portal) +interface BillingPortalRequest { + return_url: string +} + +interface BillingPortalResponse { + billing_portal_url: string +} + +interface CreateWorkspacePayload { + name: string +} + +interface UpdateWorkspacePayload { + name: string +} + +// API responses +interface ListWorkspacesResponse { + workspaces: WorkspaceWithRole[] +} + +// Token exchange types (POST /api/auth/token) +interface ExchangeTokenRequest { + workspace_id: string +} + +export interface ExchangeTokenResponse { + token: string + expires_at: string + workspace: Workspace + role: WorkspaceRole + permissions: string[] +} + +export class WorkspaceApiError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly code?: string + ) { + super(message) + this.name = 'WorkspaceApiError' + } +} + +const workspaceApiClient = axios.create({ + headers: { + 'Content-Type': 'application/json' + } +}) + +async function withAuth( + request: (headers: AuthHeader) => Promise> +): Promise { + const authHeader = await useFirebaseAuthStore().getAuthHeader() + if (!authHeader) { + throw new WorkspaceApiError( + t('toastMessages.userNotAuthenticated'), + 401, + 'NOT_AUTHENTICATED' + ) + } + try { + const response = await request(authHeader) + return response.data + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status + const message = err.response?.data?.message ?? err.message + throw new WorkspaceApiError(message, status) + } + throw err + } +} + +/** + * Wrapper that uses Firebase ID token directly (not workspace token). + * Used for token exchange where we need the Firebase token to get a workspace token. + */ +async function withFirebaseAuth( + request: (headers: AuthHeader) => Promise> +): Promise { + const firebaseToken = await useFirebaseAuthStore().getIdToken() + if (!firebaseToken) { + throw new WorkspaceApiError( + t('toastMessages.userNotAuthenticated'), + 401, + 'NOT_AUTHENTICATED' + ) + } + const headers: AuthHeader = { Authorization: `Bearer ${firebaseToken}` } + try { + const response = await request(headers) + return response.data + } catch (err) { + if (axios.isAxiosError(err)) { + const status = err.response?.status + const message = err.response?.data?.message ?? err.message + const code = + status === 401 + ? 'INVALID_FIREBASE_TOKEN' + : status === 403 + ? 'ACCESS_DENIED' + : status === 404 + ? 'WORKSPACE_NOT_FOUND' + : 'TOKEN_EXCHANGE_FAILED' + throw new WorkspaceApiError(message, status, code) + } + throw err + } +} + +export const workspaceApi = { + /** + * List all workspaces the user has access to + * GET /api/workspaces + */ + list: (): Promise => + withAuth((headers) => + workspaceApiClient.get(api.apiURL('/workspaces'), { headers }) + ), + + /** + * Create a new workspace + * POST /api/workspaces + */ + create: (payload: CreateWorkspacePayload): Promise => + withAuth((headers) => + workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers }) + ), + + /** + * Update workspace name + * PATCH /api/workspaces/:id + */ + update: ( + workspaceId: string, + payload: UpdateWorkspacePayload + ): Promise => + withAuth((headers) => + workspaceApiClient.patch( + api.apiURL(`/workspaces/${workspaceId}`), + payload, + { headers } + ) + ), + + /** + * Delete a workspace (owner only) + * DELETE /api/workspaces/:id + */ + delete: (workspaceId: string): Promise => + withAuth((headers) => + workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), { + headers + }) + ), + + /** + * Leave the current workspace. + * POST /api/workspace/leave + */ + leave: (): Promise => + withAuth((headers) => + workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers }) + ), + + /** + * List workspace members (paginated). + * GET /api/workspace/members + */ + listMembers: (params?: ListMembersParams): Promise => + withAuth((headers) => + workspaceApiClient.get(api.apiURL('/workspace/members'), { + headers, + params + }) + ), + + /** + * Remove a member from the workspace. + * DELETE /api/workspace/members/:userId + */ + removeMember: (userId: string): Promise => + withAuth((headers) => + workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), { + headers + }) + ), + + /** + * List pending invites for the workspace. + * GET /api/workspace/invites + */ + listInvites: (): Promise => + withAuth((headers) => + workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers }) + ), + + /** + * Create an invite for the workspace. + * POST /api/workspace/invites + */ + createInvite: (payload: CreateInviteRequest): Promise => + withAuth((headers) => + workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, { + headers + }) + ), + + /** + * Revoke a pending invite. + * DELETE /api/workspace/invites/:inviteId + */ + revokeInvite: (inviteId: string): Promise => + withAuth((headers) => + workspaceApiClient.delete(api.apiURL(`/workspace/invites/${inviteId}`), { + headers + }) + ), + + /** + * Accept a workspace invite. + * POST /api/invites/:token/accept + */ + acceptInvite: (token: string): Promise => + withAuth((headers) => + workspaceApiClient.post(api.apiURL(`/invites/${token}/accept`), null, { + headers + }) + ), + + /** + * Exchange Firebase JWT for workspace-scoped Cloud JWT. + * POST /api/auth/token + * + * Uses Firebase ID token directly (not getAuthHeader) since we're + * exchanging it for a workspace-scoped token. + */ + exchangeToken: (workspaceId: string): Promise => + withFirebaseAuth((headers) => + workspaceApiClient.post( + api.apiURL('/auth/token'), + { workspace_id: workspaceId } satisfies ExchangeTokenRequest, + { headers } + ) + ), + + /** + * Access the billing portal for the current workspace. + * POST /api/billing/portal + * + * Uses workspace-scoped token to get billing portal URL. + */ + accessBillingPortal: (returnUrl?: string): Promise => + withAuth((headers) => + workspaceApiClient.post( + api.apiURL('/billing/portal'), + { + return_url: returnUrl ?? window.location.href + } satisfies BillingPortalRequest, + { headers } + ) + ) +} diff --git a/src/platform/workspace/services/sessionManager.ts b/src/platform/workspace/services/sessionManager.ts new file mode 100644 index 000000000..c26859a75 --- /dev/null +++ b/src/platform/workspace/services/sessionManager.ts @@ -0,0 +1,148 @@ +import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' + +/** + * Session manager for workspace context. + * Handles sessionStorage operations and page reloads for workspace switching. + */ +export const sessionManager = { + /** + * Get the current workspace ID from sessionStorage + */ + getCurrentWorkspaceId(): string | null { + try { + return sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE) + } catch { + return null + } + }, + + /** + * Set the current workspace ID in sessionStorage + */ + setCurrentWorkspaceId(workspaceId: string): void { + try { + sessionStorage.setItem( + WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE, + workspaceId + ) + } catch { + console.warn('Failed to set workspace ID in sessionStorage') + } + }, + + /** + * Clear the current workspace ID from sessionStorage + */ + clearCurrentWorkspaceId(): void { + try { + sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE) + } catch { + console.warn('Failed to clear workspace ID from sessionStorage') + } + }, + + /** + * Get the last workspace ID from localStorage (cross-session persistence) + */ + getLastWorkspaceId(): string | null { + try { + return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID) + } catch { + return null + } + }, + + /** + * Persist the last workspace ID to localStorage + */ + setLastWorkspaceId(workspaceId: string): void { + try { + localStorage.setItem( + WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID, + workspaceId + ) + } catch { + console.warn('Failed to persist last workspace ID to localStorage') + } + }, + + /** + * Clear the last workspace ID from localStorage + */ + clearLastWorkspaceId(): void { + try { + localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID) + } catch { + console.warn('Failed to clear last workspace ID from localStorage') + } + }, + + /** + * Get the workspace token and expiry from sessionStorage + */ + getWorkspaceToken(): { token: string; expiresAt: number } | null { + try { + const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN) + const expiresAtStr = sessionStorage.getItem( + WORKSPACE_STORAGE_KEYS.EXPIRES_AT + ) + if (!token || !expiresAtStr) return null + + const expiresAt = parseInt(expiresAtStr, 10) + if (isNaN(expiresAt)) return null + + return { token, expiresAt } + } catch { + return null + } + }, + + /** + * Store the workspace token and expiry in sessionStorage + */ + setWorkspaceToken(token: string, expiresAt: number): void { + try { + sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token) + sessionStorage.setItem( + WORKSPACE_STORAGE_KEYS.EXPIRES_AT, + expiresAt.toString() + ) + } catch { + console.warn('Failed to set workspace token in sessionStorage') + } + }, + + /** + * Clear the workspace token from sessionStorage + */ + clearWorkspaceToken(): void { + try { + sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN) + sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT) + } catch { + console.warn('Failed to clear workspace token from sessionStorage') + } + }, + + /** + * Switch workspace and reload the page. + * Clears the old workspace token before reload so fresh token is fetched. + * Code after calling this won't execute (page is gone). + */ + switchWorkspaceAndReload(workspaceId: string): void { + this.clearWorkspaceToken() + this.setCurrentWorkspaceId(workspaceId) + this.setLastWorkspaceId(workspaceId) + window.location.reload() + }, + + /** + * Clear workspace context and reload (e.g., after deletion). + * Falls back to personal workspace on next boot. + */ + clearAndReload(): void { + this.clearWorkspaceToken() + this.clearCurrentWorkspaceId() + window.location.reload() + } +}