feat(workspace): add foundation layer - API client and session manager

- Add workspaceApi.ts with all workspace endpoint methods
- Add sessionManager.ts for workspace token storage
- Update workspaceConstants.ts with storage keys
- Add INVITE namespace to preservedQueryNamespaces
This commit is contained in:
--list
2026-01-20 11:55:00 -08:00
parent b5f91977c8
commit a0dc6432fc
4 changed files with 488 additions and 2 deletions

View File

@@ -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

View File

@@ -1,3 +1,4 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template'
TEMPLATE: 'template',
INVITE: 'invite'
} as const

View File

@@ -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<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
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<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
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<ListWorkspacesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspaces'), { headers })
),
/**
* Create a new workspace
* POST /api/workspaces
*/
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers })
),
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
update: (
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.patch(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
),
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
delete: (workspaceId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), {
headers
})
),
/**
* Leave the current workspace.
* POST /api/workspace/leave
*/
leave: (): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers })
),
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
listMembers: (params?: ListMembersParams): Promise<ListMembersResponse> =>
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<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), {
headers
})
),
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
listInvites: (): Promise<ListInvitesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers })
),
/**
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
createInvite: (payload: CreateInviteRequest): Promise<PendingInvite> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, {
headers
})
),
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
revokeInvite: (inviteId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/invites/${inviteId}`), {
headers
})
),
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
*/
acceptInvite: (token: string): Promise<AcceptInviteResponse> =>
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<ExchangeTokenResponse> =>
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<BillingPortalResponse> =>
withAuth((headers) =>
workspaceApiClient.post(
api.apiURL('/billing/portal'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
{ headers }
)
)
}

View File

@@ -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()
}
}