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