mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const PRESERVED_QUERY_NAMESPACES = {
|
||||
TEMPLATE: 'template'
|
||||
TEMPLATE: 'template',
|
||||
INVITE: 'invite'
|
||||
} as const
|
||||
|
||||
334
src/platform/workspace/api/workspaceApi.ts
Normal file
334
src/platform/workspace/api/workspaceApi.ts
Normal 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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
148
src/platform/workspace/services/sessionManager.ts
Normal file
148
src/platform/workspace/services/sessionManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user