diff --git a/frontend/src/app/LoginRoute.tsx b/frontend/src/app/LoginRoute.tsx index df75310..1915506 100644 --- a/frontend/src/app/LoginRoute.tsx +++ b/frontend/src/app/LoginRoute.tsx @@ -1,3 +1,4 @@ +/* global PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import LoginView from '../login/LoginView'; @@ -9,7 +10,15 @@ import { serializeAuthenticationCredential, serializeRegistrationCredential, } from '../utils/webauthn'; -import { api, useAppDispatch, useAppState } from './appState'; +import { useAppDispatch, useAppState } from './appState'; +import { + finishPasskeyLogin, + finishSignup, + performLogin, + selectTenant, + startPasskeyLogin, + startSignup, +} from '../lib/apiClient'; type StatusVariant = 'info' | 'success' | 'error'; @@ -28,6 +37,11 @@ interface TenantSelectionState { tenants?: TenantOption[]; } +type AuthResponse = { + access_token?: string; + tenant?: TenantOption | null; + tenants?: TenantOption[]; +}; const LoginRoute: React.FC = () => { const appState = useAppState(); @@ -161,23 +175,19 @@ const LoginRoute: React.FC = () => { try { setSelectingTenantId(tenant.id); - const { data } = await api.post( - '/auth/select-tenant', + const data = await selectTenant( { tenant_id: tenant.id }, - { - headers: { - Authorization: `Bearer ${tenantSelection.selectionToken}`, - }, - }, - ); + tenantSelection.selectionToken, + ) as AuthResponse; - if (!data?.access_token) { + const accessToken = data?.access_token; + if (!accessToken) { throw new Error('Invalid tenant selection response.'); } appDispatch({ type: 'LOGIN_SUCCESS', - token: data.access_token, + token: accessToken, tenant: data.tenant || null, }); setStatusMessage('Login successful.', 'success'); @@ -211,9 +221,9 @@ const LoginRoute: React.FC = () => { setPasskeyLoading(true); appDispatch({ type: 'LOGIN_REQUEST' }); try { - const { data: startData } = await api.post('/auth/passkeys/login/start', { username }); - const challengeId = startData.challengeId; - const publicKeyOptions = startData.publicKey; + const startData = await startPasskeyLogin(username); + const challengeId = (startData as { challengeId?: string })?.challengeId; + const publicKeyOptions = (startData as { publicKey?: PublicKeyCredentialRequestOptions })?.publicKey; if (!challengeId || !publicKeyOptions) { throw new Error('Invalid passkey challenge response.'); @@ -239,13 +249,13 @@ const LoginRoute: React.FC = () => { credential: serialized, }; - const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload); + const finishData = await finishPasskeyLogin(finishPayload) as AuthResponse; - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + if (finishData?.access_token && Array.isArray(finishData.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: finishData.access_token, - tenants: finishData.tenants, + tenants: finishData.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; @@ -322,16 +332,16 @@ const LoginRoute: React.FC = () => { payload.preferred_tenant_id = magicPreferredTenantId; } - const { data } = await api.post('/auth/login', payload); + const data = await performLogin(payload) as AuthResponse; if (cancelled) { return; } - if (data?.access_token && Array.isArray(data?.tenants)) { + if (data?.access_token && Array.isArray(data.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: data.access_token, - tenants: data.tenants, + tenants: data.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; @@ -416,7 +426,10 @@ const LoginRoute: React.FC = () => { setSignupLoading(true); try { - const { data: startData } = await api.post('/auth/signup/start', { username }); + const startData = await startSignup(username) as { + signup_token?: string; + challenge?: { challengeId?: string; publicKey?: PublicKeyCredentialCreationOptions }; + }; const signupToken = startData.signup_token; const challengePayload = startData.challenge; const challengeId = challengePayload?.challengeId; @@ -446,13 +459,13 @@ const LoginRoute: React.FC = () => { credential: serialized, }; - const { data: finishData } = await api.post('/auth/signup/finish', finishPayload); + const finishData = await finishSignup(finishPayload) as AuthResponse; - if (finishData?.access_token && Array.isArray(finishData?.tenants)) { + if (finishData?.access_token && Array.isArray(finishData.tenants)) { appDispatch({ type: 'TENANT_SELECTION_REQUIRED', selectionToken: finishData.access_token, - tenants: finishData.tenants, + tenants: finishData.tenants || [], }); setStatusMessage('Select a tenant to continue.', 'info'); return; diff --git a/frontend/src/app/SettingsRoute.tsx b/frontend/src/app/SettingsRoute.tsx index fb86cb3..6e85bae 100644 --- a/frontend/src/app/SettingsRoute.tsx +++ b/frontend/src/app/SettingsRoute.tsx @@ -4,7 +4,6 @@ import { useAppShell } from '../appShellContext'; import useApiTokens from '../settings/useApiTokens'; import useCapabilitySets from '../settings/useCapabilitySets'; import useCapabilities from '../settings/useCapabilities'; -import { api } from './appState'; interface SettingsRouteProps { open?: boolean; @@ -38,7 +37,7 @@ const SettingsRoute: React.FC = ({ open = true, onClose }) = revoke: revokeToken, regenerate: regenerateToken, dismissSecret, - } = useApiTokens({ api, token, notifyApiError, setStatusMessage }); + } = useApiTokens({ token, notifyApiError, setStatusMessage }); const { capabilitySets, @@ -51,7 +50,7 @@ const SettingsRoute: React.FC = ({ open = true, onClose }) = createCapabilitySet, updateCapabilitySet, deleteCapabilitySet, - } = useCapabilitySets({ api, token, notifyApiError, setStatusMessage }); + } = useCapabilitySets({ token, notifyApiError, setStatusMessage }); const { capabilities, diff --git a/frontend/src/app/appState.tsx b/frontend/src/app/appState.tsx index e728128..cc05244 100644 --- a/frontend/src/app/appState.tsx +++ b/frontend/src/app/appState.tsx @@ -1,5 +1,6 @@ import React, { useContext, useEffect, useMemo, useReducer } from 'react'; import api from '../lib/api'; +import { listTenants } from '../lib/apiClient'; type Tenant = Record | null; @@ -217,11 +218,11 @@ const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children } } try { - const { data } = await api.get('/tenants'); if (!abort) { + const tenants = await listTenants(); dispatch({ type: 'SET_TENANTS', - tenants: Array.isArray(data?.tenants) ? data.tenants : [], + tenants, }); } } catch (error) { diff --git a/frontend/src/documents/hooks/useBulkDocumentActions.ts b/frontend/src/documents/hooks/useBulkDocumentActions.ts index 47d436a..275c46f 100644 --- a/frontend/src/documents/hooks/useBulkDocumentActions.ts +++ b/frontend/src/documents/hooks/useBulkDocumentActions.ts @@ -1,12 +1,8 @@ import { useCallback } from 'react'; +import { assignCorrespondentsBulk } from '../../lib/apiClient'; export type Identifier = string | number; -type ApiClient = { - post: (url: string, payload: unknown) => Promise<{ data: T } | T>; - delete: (url: string) => Promise; -}; - type BulkAssignmentResponse = { assigned?: number; removed?: number; @@ -17,7 +13,6 @@ type CorrespondentAssignment = { }; interface UseBulkDocumentActionsArgs { - api: ApiClient; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; correspondentLookupByName: Map; handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>; @@ -32,7 +27,6 @@ interface UseBulkDocumentActionsArgs { } const useBulkDocumentActions = ({ - api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, @@ -72,7 +66,7 @@ const useBulkDocumentActions = ({ return; } - const response = await api.post('/documents/bulk/correspondents', { + const response: BulkAssignmentResponse = await assignCorrespondentsBulk({ document_ids: targets, assignments: [ { @@ -82,7 +76,7 @@ const useBulkDocumentActions = ({ action: 'add', }); - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; + const { assigned = 0, removed = 0 } = response; if (updateDocumentCaches && target.id) { targets.forEach((docId) => { @@ -118,7 +112,6 @@ const useBulkDocumentActions = ({ } }, [ - api, correspondentLookupByName, handleCorrespondentCreate, resolveTargetDocumentIds, @@ -141,17 +134,17 @@ const useBulkDocumentActions = ({ return; } - const normalizedAssignments = assignments.map((entry) => ({ - correspondent_id: entry.correspondent_id, - })); + const normalizedAssignments = assignments.map((entry) => ({ + correspondent_id: entry.correspondent_id, + })); - const response = await api.post('/documents/bulk/correspondents', { + const response: BulkAssignmentResponse = await assignCorrespondentsBulk({ document_ids: targets, assignments: normalizedAssignments, action: 'remove', }); - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; + const { assigned = 0, removed = 0 } = response; if (updateDocumentCaches) { targets.forEach((docId) => { updateDocumentCaches(docId, (doc) => { @@ -178,12 +171,12 @@ const useBulkDocumentActions = ({ } else if (assigned > 0) { const assignedSuffix = assigned === 1 ? '' : 's'; setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); - } else { - setStatusMessage('No correspondents changed.', 'info'); - } - }, - [api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches], - ); + } else { + setStatusMessage('No correspondents changed.', 'info'); + } + }, + [resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches], +); const handleDeleteSelection = useCallback(async () => { const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; diff --git a/frontend/src/hooks/documents/useDocumentMutations.ts b/frontend/src/hooks/documents/useDocumentMutations.ts index 615bd71..7109e29 100644 --- a/frontend/src/hooks/documents/useDocumentMutations.ts +++ b/frontend/src/hooks/documents/useDocumentMutations.ts @@ -2,6 +2,17 @@ import { useCallback } from 'react'; import { isPlainObject } from '../../utils/typeGuards'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; +import { + addDocumentTags, + createTag, + deleteDocumentTag, + deleteFolder, + moveDocumentsBulk, + moveDocumentToFolder, + queueDocumentReanalysis, + trashDocument, + updateDocument, +} from '../../lib/apiClient'; type DocumentId = string | number; type FolderId = DocumentId | 'root'; @@ -35,12 +46,6 @@ type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; type SetStatusMessage = (message: string, level?: StatusLevel) => void; -interface ApiClient { - post(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; - patch(url: string, data?: unknown, config?: Record): Promise<{ data: T }>; - delete(url: string, config?: Record): Promise<{ data: T }>; -} - interface Tag { id: DocumentId; label: string; @@ -105,7 +110,6 @@ interface FolderDeleteOptions { } interface UseDocumentMutationsArgs { - api: ApiClient; token?: string | null; documentLookup: Map; folderLabelMap: Map; @@ -181,7 +185,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => { }; const useDocumentMutations = ({ - api, token, documentLookup, folderLabelMap, @@ -286,12 +289,9 @@ const useDocumentMutations = ({ setLoading(true); try { if (uniqueIds.length === 1) { - await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); + await moveDocumentToFolder(uniqueIds[0], target); } else { - await api.post('/documents/bulk/move', { - document_ids: uniqueIds, - folder_id: target, - }); + await moveDocumentsBulk(uniqueIds, target); } const count = uniqueIds.length; @@ -382,7 +382,6 @@ const useDocumentMutations = ({ } }, [ - api, documentLookup, folderLabelMap, ensureFolderData, @@ -413,9 +412,7 @@ const useDocumentMutations = ({ } setLoading(true); try { - await api.post(`/documents/${documentId}/assets`, null, { - params: { force: true }, - }); + await queueDocumentReanalysis(documentId, { force: true }); setStatusMessage('Document re-analysis queued.', 'info'); await refreshCurrentFolder(); } catch (error) { @@ -425,7 +422,7 @@ const useDocumentMutations = ({ setLoading(false); } }, - [api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading], + [token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading], ); const handleDocumentsDelete = useCallback( @@ -444,7 +441,7 @@ const useDocumentMutations = ({ } try { - await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`))); + await Promise.all(documentIds.map((documentId) => trashDocument(documentId))); removeDocumentsFromCaches(documentIds); @@ -468,7 +465,6 @@ const useDocumentMutations = ({ } }, [ - api, token, removeDocumentsFromCaches, previewDocumentId, @@ -489,7 +485,7 @@ const useDocumentMutations = ({ setLoading(true); try { - const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); + const data = await updateDocument(documentId, { title: trimmed }); const updatedDocument = extractDocumentFromResponse?.(data); if (updatedDocument && ingestDocuments) { @@ -514,7 +510,6 @@ const useDocumentMutations = ({ } }, [ - api, extractDocumentFromResponse, ingestDocuments, notifyApiError, @@ -529,7 +524,7 @@ const useDocumentMutations = ({ setLoading(true); const payload = { issued_at: nextIssuedDate || null }; try { - const { data } = await api.patch(`/documents/${documentId}`, payload); + const data = await updateDocument(documentId, payload); const updatedDocument = extractDocumentFromResponse?.(data); if (updatedDocument && ingestDocuments) { @@ -555,7 +550,6 @@ const useDocumentMutations = ({ } }, [ - api, extractDocumentFromResponse, ingestDocuments, notifyApiError, @@ -584,7 +578,7 @@ const useDocumentMutations = ({ }; try { - await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] }); + await addDocumentTags(documentId, [cachedTag.id]); updateDocumentCaches(documentId, (doc) => { if (!doc) { return doc; @@ -603,7 +597,7 @@ const useDocumentMutations = ({ return false; } }, - [api, notifyApiError, setStatusMessage, updateDocumentCaches], + [notifyApiError, setStatusMessage, updateDocumentCaches], ); const handleDocumentTagAdd = useCallback( @@ -621,8 +615,8 @@ const useDocumentMutations = ({ } try { if (!tag) { - const payload = tagManager.buildPayload({ label: normalizedLabel }); - const { data } = await api.post('/tags', payload); + const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; + const data = await createTag(payload); tag = data as Tag; await refreshTags(); } @@ -637,7 +631,7 @@ const useDocumentMutations = ({ notifyApiError(error, 'Failed to assign tag.'); } }, - [api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager], + [tags, refreshTags, attachTagToDocument, notifyApiError, tagManager], ); const handleDocumentTagAttach = useCallback( @@ -706,7 +700,7 @@ const useDocumentMutations = ({ } try { - await api.delete(`/documents/${documentId}/tags/${tagId}`); + await deleteDocumentTag(documentId, tagId); applyTagRemovalToCaches(documentId, tagId); if (refreshTagList) { await refreshTags(); @@ -721,7 +715,7 @@ const useDocumentMutations = ({ return false; } }, - [api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], + [applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], ); const handleFolderDelete = useCallback( @@ -757,7 +751,7 @@ const useDocumentMutations = ({ return false; } - await api.delete(`/folders/${folderId}`); + await deleteFolder(folderId); setFolderNodes((prev: Map) => { const next = new Map(prev); @@ -815,7 +809,6 @@ const useDocumentMutations = ({ } }, [ - api, token, ensureFolderData, selectedFolder, diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index 66eebed..9db8f63 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -566,7 +566,6 @@ const useDocumentsWorkspace = ({ registerPasskey, revokePasskey, } = usePasskeys({ - api, notifyApiError, setStatusMessage, token, @@ -815,7 +814,6 @@ const useDocumentsWorkspace = ({ handleDocumentIssuedUpdate, handleTagRemove, } = useDocumentMutations({ - api, token, documentLookup, folderLabelMap, @@ -861,7 +859,6 @@ const useDocumentsWorkspace = ({ handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ - api, token, folderNodes, setFolderNodes, @@ -996,7 +993,6 @@ const useDocumentsWorkspace = ({ handleBulkCorrespondentRemove, handleDeleteSelection, } = useBulkDocumentActions({ - api, resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, diff --git a/frontend/src/hooks/documents/useFolderTreeActions.ts b/frontend/src/hooks/documents/useFolderTreeActions.ts index 40d0bf0..2d27265 100644 --- a/frontend/src/hooks/documents/useFolderTreeActions.ts +++ b/frontend/src/hooks/documents/useFolderTreeActions.ts @@ -1,6 +1,12 @@ import { useCallback, useMemo } from 'react'; import type { DragEvent } from 'react'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; +import { + createFolder, + deleteFolder, + moveFolder as moveFolderRequest, + renameFolder as renameFolderRequest, +} from '../../lib/apiClient'; type FolderId = string | number; type FolderKey = FolderId | 'root'; @@ -22,12 +28,6 @@ interface FolderContentsState { [key: string]: unknown; } -interface ApiClient { - patch: (url: string, data?: unknown) => Promise; - post: (url: string, data?: unknown) => Promise<{ data: any }>; - delete: (url: string) => Promise; -} - interface EnsureFolderOptions { force?: boolean; includeDocuments?: boolean; @@ -53,7 +53,6 @@ interface FolderClickHandlers { } interface UseFolderTreeActionsOptions { - api: ApiClient; token?: string | null; folderNodes: Map; setFolderNodes: (updater: (prev: Map) => Map) => void; @@ -84,7 +83,6 @@ interface UseFolderTreeActionsOptions { } const useFolderTreeActions = ({ - api, token, folderNodes, setFolderNodes, @@ -129,7 +127,7 @@ const useFolderTreeActions = ({ const parent_id = targetKey === 'root' ? null : targetKey; try { - await api.patch(`/folders/${folderId}`, { parent_id }); + await moveFolderRequest(folderId, parent_id); setFolderNodes((prev) => { const next = new Map(prev); @@ -202,7 +200,6 @@ const useFolderTreeActions = ({ } }, [ - api, ensureFolderData, folderNodes, notifyApiError, @@ -295,7 +292,7 @@ const useFolderTreeActions = ({ setLoading(true); try { - await api.patch(`/folders/${folderId}`, { name: trimmed }); + await renameFolderRequest(folderId, trimmed); setFolderNodes((prev) => { const next = new Map(prev); @@ -331,7 +328,6 @@ const useFolderTreeActions = ({ } }, [ - api, notifyApiError, setCurrentFolder, setFolderContents, @@ -359,33 +355,37 @@ const useFolderTreeActions = ({ setCreatingFolder(true); let succeeded = false; try { - const { data } = await api.post('/folders', payload); + const data = await createFolder(payload); + const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder; + if (!folderData?.id) { + throw new Error('Folder creation failed.'); + } setStatusMessage('Folder created.', 'success'); setFolderNodes((prev) => { const next = new Map(prev); - const parentId = payload.parent_id || 'root'; + const parentId = folderData.parent_id ?? payload.parent_id ?? 'root'; const parentNode = next.get(parentId); if (parentNode) { next.set(parentId, { ...parentNode, - children: parentNode.children.concat([data.folder.id]), + children: parentNode.children.concat([folderData.id]), loaded: true, hasChildren: true, }); } - next.set(data.folder.id, { - id: data.folder.id, - name: data.folder.name, + next.set(folderData.id, { + id: folderData.id, + name: folderData.name ?? payload.name, parentId: parentId, - children: [], + children: folderData.children || [], expanded: false, loaded: false, - hasChildren: false, + hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false, }); return next; }); await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); - await selectFolder(data.folder.id, { immediate: true }); + await selectFolder(folderData.id, { immediate: true }); succeeded = true; return true; } catch (error) { @@ -400,7 +400,6 @@ const useFolderTreeActions = ({ } }, [ - api, ensureFolderData, notifyApiError, selectFolder, @@ -445,7 +444,7 @@ const useFolderTreeActions = ({ return false; } - await api.delete(`/folders/${folderId}`); + await deleteFolder(folderId); setFolderNodes((prev) => { const next = new Map(prev); @@ -503,7 +502,6 @@ const useFolderTreeActions = ({ } }, [ - api, token, applySelectedFolder, ensureFolderData, diff --git a/frontend/src/lib/apiClient.ts b/frontend/src/lib/apiClient.ts index 0ffbac9..66115a0 100644 --- a/frontend/src/lib/apiClient.ts +++ b/frontend/src/lib/apiClient.ts @@ -9,6 +9,8 @@ import type { FolderTreeNode, Identifier, PasskeySummary, + TenantSnippet, + TagResponse, } from './apiTypes'; const normalizeNumber = (value: unknown): number | undefined => { @@ -75,6 +77,176 @@ export const listPasskeys = async (): Promise => { return Array.isArray(data) ? data : []; }; +export const moveDocumentsBulk = async (documentIds: Identifier[], folderId: Identifier | null): Promise => { + await api.post('/documents/bulk/move', { + document_ids: documentIds, + folder_id: folderId, + }); +}; + +export const queueDocumentReanalysis = async ( + documentId: Identifier, + options: { force?: boolean } = {}, +): Promise => { + const { force = false } = options; + await api.post(`/documents/${documentId}/assets`, null, { params: { force } }); +}; + +export const trashDocument = async (documentId: Identifier): Promise => { + await api.post(`/documents/${documentId}/trash`); +}; + +export const addDocumentTags = async (documentId: Identifier, tagIds: Identifier[]): Promise => { + await api.post(`/documents/${documentId}/tags`, { tag_ids: tagIds }); +}; + +export const createTag = async (payload: { label: string; color?: string | null }): Promise => { + const { data } = await api.post('/tags', payload); + return data; +}; + +export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise => { + const { data } = await api.post('/folders', payload); + return data; +}; + +export const assignCorrespondentsBulk = async ( + payload: Record, +): Promise => { + const { data } = await api.post('/documents/bulk/correspondents', payload); + return data; +}; + +export const createApiToken = async (payload: { + capability_set_id: Identifier; + label?: string; + expires_at?: string; +}): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post('/profile/api-tokens', payload); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const regenerateApiToken = async ( + tokenId: Identifier, +): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const startPasskeyRegistration = async (): Promise => { + const { data } = await api.post('/auth/passkeys/register/start', {}); + return data; +}; + +export const finishPasskeyRegistration = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/register/finish', payload); + return data; +}; + +export const startPasskeyLogin = async (username: string): Promise => { + const { data } = await api.post('/auth/passkeys/login/start', { username }); + return data; +}; + +export const finishPasskeyLogin = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/login/finish', payload); + return data; +}; + +export const performLogin = async (payload: Record): Promise => { + const { data } = await api.post('/auth/login', payload); + return data; +}; + +export const selectTenant = async ( + payload: { tenant_id: Identifier }, + selectionToken: string, +): Promise => { + const { data } = await api.post('/auth/select-tenant', payload, { + headers: { + Authorization: `Bearer ${selectionToken}`, + }, + }); + return data; +}; + +export const startSignup = async (username: string): Promise => { + const { data } = await api.post('/auth/signup/start', { username }); + return data; +}; + +export const finishSignup = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/signup/finish', payload); + return data; +}; + +export const updateDocument = async ( + id: Identifier, + payload: Record, +): Promise => { + const { data } = await api.patch(`/documents/${id}`, payload); + return data; +}; + +export const moveDocumentToFolder = async (id: Identifier, folderId: Identifier | null): Promise => { + await api.patch(`/documents/${id}/folder`, { folder_id: folderId }); +}; + +export const deleteDocumentTag = async (documentId: Identifier, tagId: Identifier): Promise => { + await api.delete(`/documents/${documentId}/tags/${tagId}`); +}; + +export const deleteFolder = async (folderId: Identifier): Promise => { + await api.delete(`/folders/${folderId}`); +}; + +export const moveFolder = async (folderId: Identifier, parentId: Identifier | null): Promise => { + await api.patch(`/folders/${folderId}`, { parent_id: parentId }); +}; + +export const renameFolder = async (folderId: Identifier, name: string): Promise => { + await api.patch(`/folders/${folderId}`, { name }); +}; + +export const createCapabilitySet = async ( + payload: { slug?: string; label?: string; capabilities: string[] }, +): Promise => { + const { data } = await api.post('/capability-sets', payload); + return data; +}; + +export const updateCapabilitySet = async ( + id: Identifier, + payload: { slug?: string; label?: string; capabilities?: string[] }, +): Promise => { + const { data } = await api.patch(`/capability-sets/${id}`, payload); + return data; +}; + +export const deleteCapabilitySet = async (id: Identifier): Promise => { + await api.delete(`/capability-sets/${id}`); +}; + +export const deleteApiToken = async (tokenId: Identifier): Promise => { + await api.delete(`/profile/api-tokens/${tokenId}`); +}; + +export const deletePasskey = async ( + passkeyId: Identifier, + options: { reason?: string } = {}, +): Promise => { + const query = options.reason ? `?reason=${encodeURIComponent(options.reason)}` : ''; + await api.delete(`/profile/passkeys/${passkeyId}${query}`); +}; + +export const listTenants = async (): Promise => { + const { data } = await api.get<{ tenants?: TenantSnippet[] } | TenantSnippet[]>('/tenants'); + if (Array.isArray(data)) { + return data; + } + return Array.isArray(data?.tenants) ? data.tenants : []; +}; + export type { DownloadLink, DocumentResponse, @@ -85,4 +257,5 @@ export type { ApiTokenRecord, PasskeySummary, Identifier, + TenantSnippet, } from './apiTypes'; diff --git a/frontend/src/settings/useApiTokens.ts b/frontend/src/settings/useApiTokens.ts index c889fa5..23b234c 100644 --- a/frontend/src/settings/useApiTokens.ts +++ b/frontend/src/settings/useApiTokens.ts @@ -1,14 +1,11 @@ import { useCallback, useState } from 'react'; - -interface ApiTokenRecord { - id?: string | number; - label?: string; - expires_at?: string; - created_at?: string; - last_used_at?: string; - capability_set_id?: string | number; - [key: string]: unknown; -} +import { + createApiToken, + deleteApiToken, + listApiTokens, + regenerateApiToken, + type ApiTokenRecord, +} from '../lib/apiClient'; interface ApiTokensResponse { token_info?: ApiTokenRecord; @@ -22,11 +19,6 @@ interface CreateTokenArgs { } interface UseApiTokensArgs { - api: { - get: (url: string) => Promise<{ data: T }>; - post: (url: string, payload?: unknown) => Promise<{ data: T }>; - delete: (url: string) => Promise; - }; notifyApiError?: (error: unknown, message: string) => void; setStatusMessage?: (message: string, variant?: string) => void; token?: string | null; @@ -46,7 +38,7 @@ interface UseApiTokensResult { dismissSecret: () => void; } -const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => { +const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => { const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); @@ -60,14 +52,14 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo } setLoading(true); try { - const { data } = await api.get('/profile/api-tokens'); + const data = await listApiTokens(); setTokens(Array.isArray(data) ? data : []); } catch (error) { notifyApiError?.(error, 'Failed to load API tokens.'); } finally { setLoading(false); } - }, [api, notifyApiError, token]); + }, [notifyApiError, token]); const create = useCallback( async ({ label, expires_at, capability_set_id }: CreateTokenArgs = {}) => { @@ -76,7 +68,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo } setCreating(true); try { - const payload: Record = { capability_set_id }; + const payload: { capability_set_id: string | number; label?: string; expires_at?: string } = { capability_set_id }; if (label) { payload.label = label; } @@ -84,7 +76,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo payload.expires_at = expires_at; } - const { data } = await api.post('/profile/api-tokens', payload); + const data = await createApiToken(payload) as ApiTokensResponse; if (data?.token_info) { setTokens((previous) => { const filtered = previous.filter((entry) => entry.id !== data.token_info?.id); @@ -107,7 +99,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo setCreating(false); } }, - [api, creating, notifyApiError, refresh, setStatusMessage], + [creating, notifyApiError, refresh, setStatusMessage], ); const revoke = useCallback( @@ -117,7 +109,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo } setDeletingId(tokenId); try { - await api.delete(`/profile/api-tokens/${tokenId}`); + await deleteApiToken(tokenId); await refresh(); setStatusMessage?.('API token revoked.', 'success'); return true; @@ -128,7 +120,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo setDeletingId(null); } }, - [api, notifyApiError, refresh, setStatusMessage], + [notifyApiError, refresh, setStatusMessage], ); const regenerate = useCallback( @@ -138,7 +130,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo } setRegeneratingId(tokenId); try { - const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`); + const data = await regenerateApiToken(tokenId) as ApiTokensResponse; if (data?.token_info) { setTokens((previous) => { let found = false; @@ -171,7 +163,7 @@ const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }: UseApiTo setRegeneratingId(null); } }, - [api, notifyApiError, refresh, setStatusMessage], + [notifyApiError, refresh, setStatusMessage], ); const dismissSecret = useCallback(() => { diff --git a/frontend/src/settings/useCapabilitySets.ts b/frontend/src/settings/useCapabilitySets.ts index cb13509..5bef981 100644 --- a/frontend/src/settings/useCapabilitySets.ts +++ b/frontend/src/settings/useCapabilitySets.ts @@ -1,5 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import { listCapabilitySets } from '../lib/apiClient'; +import { + createCapabilitySet as createCapabilitySetRequest, + deleteCapabilitySet as deleteCapabilitySetRequest, + listCapabilitySets, + updateCapabilitySet as updateCapabilitySetRequest, +} from '../lib/apiClient'; type Identifier = string | number; @@ -11,21 +16,13 @@ interface CapabilitySet { [key: string]: unknown; } -interface ApiClient { - get(path: string): Promise<{ data: T }>; - post(path: string, payload?: unknown): Promise<{ data: T }>; - patch(path: string, payload?: unknown): Promise<{ data: T }>; - delete: (path: string) => Promise; -} - interface UseCapabilitySetsOptions { - api: ApiClient; notifyApiError?: (error: unknown, message: string) => void; setStatusMessage?: (message: string, level?: string) => void; token?: string | null; } -const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => { +const useCapabilitySets = ({ notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => { const [capabilitySets, setCapabilitySets] = useState([]); const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false); const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false); @@ -92,7 +89,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use payload.label = trimmedLabel; } - const { data } = await api.post('/capability-sets', payload); + const data = await createCapabilitySetRequest(payload); if (data) { applyCapabilitySets((previous) => { const next = previous.filter((entry) => entry?.id !== data.id); @@ -113,7 +110,6 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use } }, [ - api, applyCapabilitySets, creatingCapabilitySet, notifyApiError, @@ -154,7 +150,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use payload.capabilities = capabilities; } - const { data } = await api.patch(`/capability-sets/${capabilitySetId}`, payload); + const data = await updateCapabilitySetRequest(capabilitySetId, payload); if (data) { applyCapabilitySets((previous) => { let found = false; @@ -184,7 +180,6 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use } }, [ - api, applyCapabilitySets, notifyApiError, refreshCapabilitySets, @@ -200,7 +195,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use } setDeletingCapabilitySetId(capabilitySetId); try { - await api.delete(`/capability-sets/${capabilitySetId}`); + await deleteCapabilitySetRequest(capabilitySetId); applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId)); setStatusMessage?.('Capability set deleted.', 'success'); return true; @@ -211,7 +206,7 @@ const useCapabilitySets = ({ api, notifyApiError, setStatusMessage, token }: Use setDeletingCapabilitySetId(null); } }, - [api, applyCapabilitySets, notifyApiError, setStatusMessage], + [applyCapabilitySets, notifyApiError, setStatusMessage], ); return { diff --git a/frontend/src/settings/usePasskeys.ts b/frontend/src/settings/usePasskeys.ts index 9e891a0..18d0821 100644 --- a/frontend/src/settings/usePasskeys.ts +++ b/frontend/src/settings/usePasskeys.ts @@ -6,12 +6,12 @@ import { preparePublicKeyCreationOptions, serializeRegistrationCredential, } from '../utils/webauthn'; - -type ApiClient = { - get: (path: string) => Promise<{ data: unknown }>; - post: (path: string, body?: unknown) => Promise<{ data: unknown }>; - delete: (path: string) => Promise<{ data: unknown }>; -}; +import { + deletePasskey, + finishPasskeyRegistration, + listPasskeys, + startPasskeyRegistration, +} from '../lib/apiClient'; type StatusMessageFn = (message: string, variant?: string) => void; type NotifyApiErrorFn = (error: unknown, message: string) => void; @@ -69,7 +69,6 @@ export type RevokePasskeyResult = | { ok: false; reason: RevokePasskeyFailureReason; message?: string }; interface UsePasskeysArgs { - api: ApiClient; notifyApiError: NotifyApiErrorFn; setStatusMessage: StatusMessageFn; token?: string | null; @@ -89,7 +88,7 @@ interface UsePasskeysResult { ) => Promise; } -const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasskeysArgs): UsePasskeysResult => { +const usePasskeys = ({ notifyApiError, setStatusMessage, token }: UsePasskeysArgs): UsePasskeysResult => { const [passkeys, setPasskeys] = useState([]); const [passkeysSupported, setPasskeysSupported] = useState(null); const [passkeysLoading, setPasskeysLoading] = useState(false); @@ -102,8 +101,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske } setPasskeysLoading(true); try { - const { data } = await api.get('/profile/passkeys'); - const passkeyData = Array.isArray(data) ? (data as PasskeyRecord[]) : []; + const passkeyData = await listPasskeys(); setPasskeys(passkeyData); setPasskeysSupported(true); } catch (error) { @@ -117,7 +115,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske } finally { setPasskeysLoading(false); } - }, [api, notifyApiError, token]); + }, [notifyApiError, token]); const registerPasskey = useCallback( async ({ nickname }: { nickname?: string } = {}): Promise => { @@ -132,7 +130,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske setRegisteringPasskey(true); try { - const { data } = await api.post('/auth/passkeys/register/start', {}); + const data = await startPasskeyRegistration(); const challengeData = data as PasskeyChallengeResponse | undefined; const challengeId = challengeData?.challengeId || challengeData?.challenge_id; const publicKeyOptions = @@ -164,7 +162,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske payload.nickname = trimmedNickname; } - await api.post('/auth/passkeys/register/finish', payload); + await finishPasskeyRegistration(payload); await refreshPasskeys(); setPasskeysSupported(true); setStatusMessage('Passkey registered.', 'success'); @@ -188,7 +186,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske setRegisteringPasskey(false); } }, - [api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage], + [notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage], ); const revokePasskey = useCallback( @@ -201,8 +199,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske } setRevokingPasskeyId(passkeyId); try { - const query = reason ? `?reason=${encodeURIComponent(reason)}` : ''; - await api.delete(`/profile/passkeys/${passkeyId}${query}`); + await deletePasskey(passkeyId, { reason }); await refreshPasskeys(); setStatusMessage('Passkey revoked.', 'success'); return { ok: true }; @@ -214,7 +211,7 @@ const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }: UsePasske setRevokingPasskeyId(null); } }, - [api, notifyApiError, refreshPasskeys, setStatusMessage], + [notifyApiError, refreshPasskeys, setStatusMessage], ); return {