@baqhub/sdk-react
Version:
The official React SDK for the BAQ federated app platform.
324 lines (323 loc) • 14.2 kB
JavaScript
import { AbortedError, AnyRecord, AppRecord, AppScopes, Authentication, EntityRecord, Http, HttpStatusCode, RequestError, unreachable, } from "@baqhub/sdk";
import isEqual from "lodash/isEqual.js";
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { abortable } from "../helpers/hooks.js";
import { InvalidActionError } from "../helpers/stateErrors.js";
import { AuthenticationStorage, } from "../helpers/storage.js";
import { buildFetcher } from "../helpers/suspense.js";
import { StoreIdentity } from "./store/storeIdentity.js";
//
// State.
//
export var AuthenticationStatus;
(function (AuthenticationStatus) {
AuthenticationStatus["UNAUTHENTICATED"] = "unauthenticated";
AuthenticationStatus["AUTHENTICATED"] = "authenticated";
})(AuthenticationStatus || (AuthenticationStatus = {}));
export var ConnectStatus;
(function (ConnectStatus) {
ConnectStatus["IDLE"] = "idle";
ConnectStatus["CONNECTING"] = "connecting";
ConnectStatus["WAITING_ON_FLOW"] = "waiting_on_flow";
})(ConnectStatus || (ConnectStatus = {}));
export var ConnectError;
(function (ConnectError) {
ConnectError["ENTITY_NOT_FOUND"] = "entity_not_found";
ConnectError["BAD_APP_RECORD"] = "bad_app_record";
ConnectError["OTHER"] = "other";
})(ConnectError || (ConnectError = {}));
//
// Actions.
//
var UseAuthenticationActionType;
(function (UseAuthenticationActionType) {
UseAuthenticationActionType["INITIALIZE_SUCCESS"] = "INITIALIZE_SUCCESS";
UseAuthenticationActionType["CONNECT_START"] = "CONNECT_START";
UseAuthenticationActionType["CONNECT_SUCCESS"] = "CONNECT_SUCCESS";
UseAuthenticationActionType["CONNECT_FAILURE"] = "CONNECT_FAILURE";
UseAuthenticationActionType["AUTHORIZATION_SUCCESS"] = "AUTHORIZATION_SUCCESS";
UseAuthenticationActionType["AUTHORIZATION_FAILURE"] = "AUTHORIZATION_FAILURE";
UseAuthenticationActionType["DISCONNECT"] = "DISCONNECT";
})(UseAuthenticationActionType || (UseAuthenticationActionType = {}));
//
// Reducer.
//
function reducer(state, action) {
switch (action.type) {
case UseAuthenticationActionType.INITIALIZE_SUCCESS:
if (state.status !== AuthenticationStatus.AUTHENTICATED ||
!("localState" in state)) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.AUTHENTICATED,
identity: action.identity || state.identity,
};
case UseAuthenticationActionType.CONNECT_START:
if (state.status !== AuthenticationStatus.UNAUTHENTICATED ||
state.connectStatus !== ConnectStatus.IDLE) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.CONNECTING,
entity: action.entity,
};
case UseAuthenticationActionType.CONNECT_SUCCESS:
if (state.status !== AuthenticationStatus.UNAUTHENTICATED ||
state.connectStatus !== ConnectStatus.CONNECTING) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.WAITING_ON_FLOW,
flowUrl: action.flowUrl,
localState: action.localState,
};
case UseAuthenticationActionType.CONNECT_FAILURE:
if (state.status !== AuthenticationStatus.UNAUTHENTICATED ||
state.connectStatus !== ConnectStatus.CONNECTING) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.IDLE,
error: action.error,
};
case UseAuthenticationActionType.AUTHORIZATION_SUCCESS:
if (state.status !== AuthenticationStatus.UNAUTHENTICATED ||
state.connectStatus !== ConnectStatus.WAITING_ON_FLOW) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.AUTHENTICATED,
localState: state.localState,
authorizationId: action.authorizationId,
identity: action.identity,
};
case UseAuthenticationActionType.AUTHORIZATION_FAILURE:
if (state.status !== AuthenticationStatus.UNAUTHENTICATED ||
state.connectStatus !== ConnectStatus.WAITING_ON_FLOW) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.IDLE,
};
case UseAuthenticationActionType.DISCONNECT:
if (state.status !== AuthenticationStatus.AUTHENTICATED) {
throw new InvalidActionError(state, action);
}
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.IDLE,
};
default:
unreachable(action);
}
}
export function buildAuthentication(options) {
const { storage, secureStorage, app } = options;
const authenticationStorage = new AuthenticationStorage(storage, secureStorage);
const findLocalState = buildFetcher(async () => {
return authenticationStorage.read();
});
function buildInitialState(newAuthorizationId) {
const localState = findLocalState();
if (!localState) {
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.IDLE,
};
}
const authorizationId = newAuthorizationId || localState.authorizationId;
if (!authorizationId) {
return {
status: AuthenticationStatus.UNAUTHENTICATED,
connectStatus: ConnectStatus.IDLE,
};
}
const localStateWithAuthorization = Authentication.complete(localState, authorizationId);
return {
status: AuthenticationStatus.AUTHENTICATED,
authorizationId,
localState,
identity: StoreIdentity.new(localStateWithAuthorization),
};
}
function useAuthentication(options = {}) {
const { appIconUrl, authorizationId } = options;
const [authenticationState, dispatch] = useReducer(reducer, authorizationId || undefined, buildInitialState);
//
// API.
//
const onConnectRequest = useCallback((entity) => {
dispatch({
type: UseAuthenticationActionType.CONNECT_START,
entity,
});
}, []);
const waitingOnFlowLocalState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED &&
authenticationState.connectStatus === ConnectStatus.WAITING_ON_FLOW &&
authenticationState;
const onAuthorizationResult = useCallback((authorizationId) => {
if (!waitingOnFlowLocalState) {
throw new Error("Authentication not waiting on flow.");
}
if (!authorizationId) {
dispatch({ type: UseAuthenticationActionType.AUTHORIZATION_FAILURE });
return;
}
const { localState } = waitingOnFlowLocalState;
const localStateWithAuthorization = {
...localState,
authorizationId: authorizationId || localState.authorizationId,
};
dispatch({
type: UseAuthenticationActionType.AUTHORIZATION_SUCCESS,
identity: StoreIdentity.new(localStateWithAuthorization),
authorizationId,
});
}, [waitingOnFlowLocalState]);
const onDisconnectRequest = useCallback(() => {
dispatch({ type: UseAuthenticationActionType.DISCONNECT });
}, []);
//
// Clear local state.
//
const unauthenticatedLocalState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED &&
authenticationState.connectStatus === ConnectStatus.IDLE &&
authenticationState;
useEffect(() => {
if (!unauthenticatedLocalState) {
return;
}
authenticationStorage.write(undefined);
}, [unauthenticatedLocalState]);
//
// Initialization.
//
const initializingLocalState = authenticationState.status === AuthenticationStatus.AUTHENTICATED &&
"localState" in authenticationState &&
authenticationState;
useEffect(() => {
if (!initializingLocalState) {
return;
}
const { authorizationId, localState, identity } = initializingLocalState;
const { findClient } = identity;
return abortable(async (signal) => {
try {
const client = findClient(localState.entityRecord.author.entity);
const [serverEntityRecord, serverAppRecord] = await Promise.all([
client.getOwnRecord(AnyRecord, EntityRecord, localState.entityRecord.id, { signal }),
client.getOwnRecord(AnyRecord, AppRecord, localState.appRecord.id, {
signal,
}),
]);
const updatedState = {
...localState,
authorizationId,
entityRecord: serverEntityRecord.record,
appRecord: serverAppRecord.record,
};
// Check compatibility of app record scopes with what we require.
if (!AppScopes.hasScopes(updatedState.appRecord, app.scopeRequest)) {
dispatch({ type: UseAuthenticationActionType.DISCONNECT });
return;
}
const authorizationIdChanged = updatedState.authorizationId !== localState.authorizationId;
const recordsChanged = !isEqual(updatedState.entityRecord, serverEntityRecord.record) ||
!isEqual(updatedState.appRecord, serverAppRecord.record);
if (authorizationIdChanged || recordsChanged) {
await authenticationStorage.write(updatedState);
}
if (!recordsChanged) {
dispatch({
type: UseAuthenticationActionType.INITIALIZE_SUCCESS,
identity: undefined,
});
return;
}
await authenticationStorage.write(updatedState);
dispatch({
type: UseAuthenticationActionType.INITIALIZE_SUCCESS,
identity: StoreIdentity.new(updatedState),
});
}
catch (error) {
if (error instanceof AbortedError || signal.aborted) {
return;
}
if (error instanceof RequestError &&
[
HttpStatusCode.NOT_FOUND,
HttpStatusCode.FORBIDDEN,
HttpStatusCode.UNAUTHORIZED,
].includes(error.status)) {
dispatch({ type: UseAuthenticationActionType.DISCONNECT });
return;
}
throw error;
}
});
}, [initializingLocalState]);
//
// Connection.
//
const appIconPromise = useMemo(() => {
if (!appIconUrl) {
return undefined;
}
if (authenticationState.status !== AuthenticationStatus.UNAUTHENTICATED) {
return undefined;
}
return Http.download(appIconUrl).then(([_, b]) => b);
}, [appIconUrl, authenticationState.status]);
const connectingState = authenticationState.status === AuthenticationStatus.UNAUTHENTICATED &&
authenticationState.connectStatus === ConnectStatus.CONNECTING &&
authenticationState;
useEffect(() => {
if (!connectingState) {
return;
}
console.log("Starting auth.");
const { entity } = connectingState;
return abortable(async (signal) => {
try {
const icon = await appIconPromise;
const auth = await Authentication.register(entity, app, {
icon,
signal,
});
const { flowUrl, state } = auth;
// Save the local state.
await authenticationStorage.write(state);
// Hand it off to the authentication flow.
dispatch({
type: UseAuthenticationActionType.CONNECT_SUCCESS,
flowUrl,
localState: state,
});
}
catch (error) {
if (error instanceof AbortedError || signal.aborted) {
return;
}
console.log("Sign-in error:", error);
dispatch({
type: UseAuthenticationActionType.CONNECT_FAILURE,
error: ConnectError.OTHER,
});
}
});
}, [appIconPromise, connectingState]);
return {
state: authenticationState,
onConnectRequest,
onAuthorizationResult,
onDisconnectRequest,
};
}
return { useAuthentication };
}