Initial commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
if (envUrl && envUrl.length > 0) {
|
||||
return envUrl.replace(/\/+$/, '');
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: unknown;
|
||||
|
||||
constructor(status: number, statusText: string, body: unknown) {
|
||||
super(`API error ${status}: ${statusText}`);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
export async function refreshAccessToken(): Promise<void> {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState();
|
||||
|
||||
if (!refreshToken || !tokenEndpoint) {
|
||||
logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
throw new Error('No refresh token or token endpoint available');
|
||||
}
|
||||
|
||||
const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
|
||||
|
||||
try {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint);
|
||||
} catch (error) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await refreshPromise;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
if (store.isTokenExpiringSoon() && store.refreshToken) {
|
||||
await refreshAccessToken();
|
||||
}
|
||||
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const { accessToken } = useAuthStore.getState();
|
||||
const url = `${getApiBaseUrl()}${path}`;
|
||||
|
||||
const headers = new Headers(options?.headers);
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
let response = await makeRequest();
|
||||
|
||||
if (response.status === 401 && useAuthStore.getState().refreshToken) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
response = await makeRequest();
|
||||
} catch {
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from './oauth';
|
||||
|
||||
const UNRESERVED_RE = /^[A-Za-z0-9\-._~]+$/;
|
||||
|
||||
describe('generateCodeVerifier', () => {
|
||||
it('defaults to 64 characters', () => {
|
||||
const verifier = generateCodeVerifier();
|
||||
expect(verifier).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('honours a custom length', () => {
|
||||
expect(generateCodeVerifier(43)).toHaveLength(43);
|
||||
expect(generateCodeVerifier(128)).toHaveLength(128);
|
||||
});
|
||||
|
||||
it('rejects lengths outside RFC 7636 range', () => {
|
||||
expect(() => generateCodeVerifier(42)).toThrow();
|
||||
expect(() => generateCodeVerifier(129)).toThrow();
|
||||
});
|
||||
|
||||
it('returns different values on each call', () => {
|
||||
const a = generateCodeVerifier();
|
||||
const b = generateCodeVerifier();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('only contains RFC 3986 unreserved characters', () => {
|
||||
const verifier = generateCodeVerifier();
|
||||
expect(verifier).toMatch(UNRESERVED_RE);
|
||||
});
|
||||
|
||||
it('consistently produces unreserved-only output across multiple calls', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const v = generateCodeVerifier();
|
||||
expect(v).toMatch(UNRESERVED_RE);
|
||||
expect(v).toHaveLength(64);
|
||||
}
|
||||
});
|
||||
|
||||
it('exercises every character in the alphabet given enough samples', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
for (const ch of generateCodeVerifier(128)) seen.add(ch);
|
||||
}
|
||||
for (const ch of '-._~') {
|
||||
expect(seen.has(ch)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCodeChallenge', () => {
|
||||
it('returns a base64url string without +, /, or =', async () => {
|
||||
const { challenge } = await generateCodeChallenge('test-verifier');
|
||||
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('produces consistent output for same input', async () => {
|
||||
const a = await generateCodeChallenge('same-input');
|
||||
const b = await generateCodeChallenge('same-input');
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('produces different output for different inputs', async () => {
|
||||
const a = await generateCodeChallenge('input-one');
|
||||
const b = await generateCodeChallenge('input-two');
|
||||
expect(a.challenge).not.toBe(b.challenge);
|
||||
});
|
||||
|
||||
it('returns a non-empty string', async () => {
|
||||
const { challenge } = await generateCodeChallenge('anything');
|
||||
expect(challenge.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a SHA-256 sized output (43 base64url chars for 32 bytes) when S256 is used', async () => {
|
||||
const { challenge, method } = await generateCodeChallenge('test');
|
||||
expect(method).toBe('S256');
|
||||
expect(challenge).toHaveLength(43);
|
||||
});
|
||||
|
||||
it('falls back to plain when crypto.subtle is unavailable', async () => {
|
||||
const originalSubtle = crypto.subtle;
|
||||
Object.defineProperty(crypto, 'subtle', {
|
||||
configurable: true,
|
||||
get: () => undefined,
|
||||
});
|
||||
try {
|
||||
const { challenge, method } = await generateCodeChallenge('plain-verifier');
|
||||
expect(method).toBe('plain');
|
||||
expect(challenge).toBe('plain-verifier');
|
||||
} finally {
|
||||
Object.defineProperty(crypto, 'subtle', {
|
||||
configurable: true,
|
||||
value: originalSubtle,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { getApiBaseUrl } from '@/services/api';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const CLIENT_ID = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
|
||||
const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
|
||||
|
||||
const SESSION_PREFIX = 'stalwart-oauth-';
|
||||
|
||||
interface DiscoveryResponse {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
export async function discover(username: string): Promise<DiscoveryResponse> {
|
||||
const url = `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
|
||||
username,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<DiscoveryResponse>;
|
||||
}
|
||||
|
||||
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
|
||||
export function generateCodeVerifier(length: number = 64): string {
|
||||
if (length < 43 || length > 128) {
|
||||
throw new Error(`code_verifier length must be 43-128, got ${length}`);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
while (out.length < length) {
|
||||
const buf = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(buf);
|
||||
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||
const b = buf[i];
|
||||
if (b < 198) {
|
||||
out.push(UNRESERVED[b % 66]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(
|
||||
verifier: string,
|
||||
): Promise<{ challenge: string; method: 'S256' | 'plain' }> {
|
||||
if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') {
|
||||
return { challenge: verifier, method: 'plain' };
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' };
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');
|
||||
return btoa(binString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
tokenEndpoint: string,
|
||||
redirectUri: string,
|
||||
): Promise<TokenResponse> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
i18n.t('oauth.tokenExchangeFailed', 'Token exchange failed: {{status}} {{statusText}}', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
function getRedirectUri(): string {
|
||||
const basePath = getBasePath();
|
||||
return `${window.location.origin}${basePath}/oauth/callback`;
|
||||
}
|
||||
|
||||
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
|
||||
const { authorization_endpoint, token_endpoint } = await discover(username);
|
||||
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
|
||||
const state = generateState();
|
||||
|
||||
const candidate = returnUrl ?? window.location.pathname + window.location.search;
|
||||
const basePath = getBasePath();
|
||||
const stripped = candidate.startsWith(basePath) ? candidate.slice(basePath.length) : candidate;
|
||||
const isAuthPath =
|
||||
stripped === '/login' ||
|
||||
stripped.startsWith('/login?') ||
|
||||
stripped === '/oauth/callback' ||
|
||||
stripped.startsWith('/oauth/callback?');
|
||||
const safeReturnUrl = isAuthPath ? '' : candidate;
|
||||
|
||||
sessionStorage.setItem(`${SESSION_PREFIX}code_verifier`, codeVerifier);
|
||||
sessionStorage.setItem(`${SESSION_PREFIX}token_endpoint`, token_endpoint);
|
||||
sessionStorage.setItem(`${SESSION_PREFIX}state`, state);
|
||||
sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: getRedirectUri(),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
state,
|
||||
login_hint: username,
|
||||
});
|
||||
|
||||
if (SCOPES && SCOPES.length > 0) {
|
||||
params.set('scope', SCOPES);
|
||||
}
|
||||
|
||||
window.location.href = `${authorization_endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function getStoredOAuthData() {
|
||||
return {
|
||||
codeVerifier: sessionStorage.getItem(`${SESSION_PREFIX}code_verifier`),
|
||||
tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`),
|
||||
state: sessionStorage.getItem(`${SESSION_PREFIX}state`),
|
||||
returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearStoredOAuthData(): void {
|
||||
sessionStorage.removeItem(`${SESSION_PREFIX}code_verifier`);
|
||||
sessionStorage.removeItem(`${SESSION_PREFIX}token_endpoint`);
|
||||
sessionStorage.removeItem(`${SESSION_PREFIX}state`);
|
||||
sessionStorage.removeItem(`${SESSION_PREFIX}return_url`);
|
||||
}
|
||||
|
||||
export function getOAuthRedirectUri(): string {
|
||||
return getRedirectUri();
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { logJmapExchange } from '@/lib/debug';
|
||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||||
|
||||
export function getAccountId(objectType: string): string {
|
||||
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
||||
if (objectType.startsWith('x:')) {
|
||||
if (!primaryAccountId) throw new Error('No primary account ID available');
|
||||
return primaryAccountId;
|
||||
}
|
||||
if (!activeAccountId) throw new Error('No active account ID available');
|
||||
return activeAccountId;
|
||||
}
|
||||
|
||||
export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortSignal): Promise<JmapMethodResponse[]> {
|
||||
const { apiUrl } = useAuthStore.getState();
|
||||
let path = apiUrl || '/jmap';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
try {
|
||||
path = new URL(path).pathname;
|
||||
} catch {
|
||||
path = '/jmap';
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const response = await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
using: JMAP_USING,
|
||||
methodCalls,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as JmapResponse;
|
||||
|
||||
logJmapExchange(methodCalls, data.methodResponses, performance.now() - startedAt);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
for (const [methodName, result, callId] of data.methodResponses) {
|
||||
if (methodName === 'error') {
|
||||
console.error(`JMAP error [${callId}]:`, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data.methodResponses;
|
||||
}
|
||||
|
||||
export async function jmapGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
ids: string[] | null,
|
||||
properties?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ids };
|
||||
if (properties) {
|
||||
args.properties = properties;
|
||||
}
|
||||
return jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||
}
|
||||
|
||||
interface JmapQueryOptions {
|
||||
filter?: Record<string, unknown>;
|
||||
sort?: Record<string, unknown>[];
|
||||
limit?: number;
|
||||
position?: number;
|
||||
anchor?: string;
|
||||
anchorOffset?: number;
|
||||
calculateTotal?: boolean;
|
||||
}
|
||||
|
||||
export async function jmapQuery(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
options: JmapQueryOptions = {},
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ...options };
|
||||
return jmapRequest([[`${objectType}/query`, args, '0']]);
|
||||
}
|
||||
|
||||
interface JmapSetOptions {
|
||||
create?: Record<string, Record<string, unknown>>;
|
||||
update?: Record<string, Record<string, unknown>>;
|
||||
destroy?: string[];
|
||||
}
|
||||
|
||||
export async function jmapSet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
options: JmapSetOptions = {},
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const args: Record<string, unknown> = { accountId, ...options };
|
||||
return jmapRequest([[`${objectType}/set`, args, '0']]);
|
||||
}
|
||||
|
||||
export async function jmapQueryAndGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
queryOptions: JmapQueryOptions = {},
|
||||
properties?: string[],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const queryArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
...queryOptions,
|
||||
};
|
||||
|
||||
const getArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
'#ids': {
|
||||
resultOf: '0',
|
||||
name: `${objectType}/query`,
|
||||
path: '/ids',
|
||||
},
|
||||
};
|
||||
if (properties) {
|
||||
getArgs.properties = properties;
|
||||
}
|
||||
|
||||
return jmapRequest([
|
||||
[`${objectType}/query`, queryArgs, '0'],
|
||||
[`${objectType}/get`, getArgs, '1'],
|
||||
]);
|
||||
}
|
||||
|
||||
export async function jmapGetBatched(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
ids: string[],
|
||||
properties?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const batchSize = useAuthStore.getState().maxObjectsInGet;
|
||||
const allItems: Record<string, unknown>[] = [];
|
||||
|
||||
for (let offset = 0; offset < ids.length; offset += batchSize) {
|
||||
if (signal?.aborted) break;
|
||||
const batchIds = ids.slice(offset, offset + batchSize);
|
||||
const args: Record<string, unknown> = { accountId, ids: batchIds };
|
||||
if (properties) args.properties = properties;
|
||||
const responses = await jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
||||
const result = responses[0];
|
||||
if (result[0] === 'error') {
|
||||
throw new Error(`JMAP ${objectType}/get error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||
}
|
||||
const list = (result[1] as { list?: Record<string, unknown>[] }).list ?? [];
|
||||
allItems.push(...list);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
export async function jmapQueryAll(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
queryOptions: JmapQueryOptions = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const allIds: string[] = [];
|
||||
let anchor: string | undefined;
|
||||
const MAX_PAGES = 1000;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
if (signal?.aborted) break;
|
||||
|
||||
const args: Record<string, unknown> = { accountId, ...queryOptions };
|
||||
if (anchor) {
|
||||
delete args.position;
|
||||
args.anchor = anchor;
|
||||
args.anchorOffset = 1;
|
||||
}
|
||||
|
||||
const responses = await jmapRequest([[`${objectType}/query`, args, '0']], signal);
|
||||
const result = responses[0];
|
||||
if (result[0] === 'error') {
|
||||
throw new Error(`JMAP ${objectType}/query error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
||||
}
|
||||
|
||||
const data = result[1] as unknown as JmapQueryResponse;
|
||||
const pageIds = data.ids ?? [];
|
||||
if (pageIds.length === 0) break;
|
||||
|
||||
allIds.push(...pageIds);
|
||||
|
||||
if (data.limit === undefined || data.limit === null) break;
|
||||
|
||||
anchor = pageIds[pageIds.length - 1];
|
||||
}
|
||||
|
||||
return allIds;
|
||||
}
|
||||
|
||||
export async function jmapQueryAllAndGet(
|
||||
objectType: string,
|
||||
accountId: string,
|
||||
queryOptions: JmapQueryOptions = {},
|
||||
properties?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ ids: string[]; list: Record<string, unknown>[] }> {
|
||||
const ids = await jmapQueryAll(objectType, accountId, queryOptions, signal);
|
||||
if (ids.length === 0) return { ids, list: [] };
|
||||
const list = await jmapGetBatched(objectType, accountId, ids, properties, signal);
|
||||
return { ids, list };
|
||||
}
|
||||
|
||||
export async function fetchSession(): Promise<Record<string, unknown>> {
|
||||
const response = await apiFetch('/jmap/session');
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export async function fetchSchema(): Promise<Schema> {
|
||||
const response = await apiFetch('/api/schema');
|
||||
return response.json() as Promise<Schema>;
|
||||
}
|
||||
|
||||
interface AccountInfoResponse {
|
||||
permissions: string[];
|
||||
edition: 'enterprise' | 'community' | 'oss';
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export async function fetchAccountInfo(): Promise<AccountInfoResponse> {
|
||||
const response = await apiFetch('/api/account');
|
||||
return response.json() as Promise<AccountInfoResponse>;
|
||||
}
|
||||
Reference in New Issue
Block a user