@kilpi/react-client
Version:
Kilpi React Client Components · Kilpi is the authorization framework for full-stack TypeScript applications, designed for flexible, powerful, agnostic, intuitive and developer friendly authorization.
195 lines (179 loc) • 5.92 kB
text/typescript
import { type AnyKilpiClient, type KilpiClientPolicy } from "@kilpi/client";
import type { Decision, DeniedDecision, GrantedDecision, PolicysetActions } from "@kilpi/core";
import { useEffect, useState } from "react";
import { useUnmountAbortSignalRef } from "./useUnmountAbortSignalRef";
/**
* Input type
*/
export type UseAuthorizeOptions = {
isDisabled?: boolean;
};
/**
* UseAuthorizeStatus
*/
export type UseAuthorizeStatus = "idle" | "pending" | "error" | "success";
/**
* Sub-utility type for just the status and flags
*/
type UseAuthorizeStatusWithFlags<TStatus extends UseAuthorizeStatus> = {
status: TStatus;
isIdle: TStatus extends "idle" ? true : false;
isError: TStatus extends "error" ? true : false;
isSuccess: TStatus extends "success" ? true : false;
isPending: TStatus extends "pending" ? true : false;
};
/**
* Utility type to get status with flags to an object
*/
type UseAuthorizeReturnForStatus<
TStatus extends UseAuthorizeStatus,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TDecision extends null | Decision<any>,
> = UseAuthorizeStatusWithFlags<TStatus> & {
error: TStatus extends "error" ? unknown : null;
isDisabled: boolean;
decision: TDecision;
granted: TDecision extends { granted: true } ? true : false;
};
/**
* Discriminated return type by status. Implemented this way to ensure typescript
* is better able to deduce the types when narrowing or using `Extract<...>`. Two
* separate discriminators for `status = success` based on `granted` flag.
*/
export type UseAuthorizeReturn<
TClient extends AnyKilpiClient,
// @ts-expect-error Unused variable for now
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TAction extends PolicysetActions<TClient["$$infer"]["policies"]>,
> =
| UseAuthorizeReturnForStatus<"idle", null>
| UseAuthorizeReturnForStatus<"pending", null>
| UseAuthorizeReturnForStatus<"error", null>
| UseAuthorizeReturnForStatus<"success", GrantedDecision<TClient["$$infer"]["subject"]>>
| UseAuthorizeReturnForStatus<"success", DeniedDecision>;
/**
* Factory for useAuthorize hook given a policy
*/
export function create_useAuthorize<
TClient extends AnyKilpiClient,
TAction extends PolicysetActions<TClient["$$infer"]["policies"]>,
>(Client: TClient, policy: KilpiClientPolicy<TClient, TAction>) {
void Client; // Unused, but needed to keep generic type
/**
* Utility to return status with flags
*/
function getStatusWithFlags<TStatus extends UseAuthorizeStatus>(status: TStatus) {
return {
status,
isIdle: status === "idle",
isError: status === "error",
isSuccess: status === "success",
isPending: status === "pending",
} as UseAuthorizeStatusWithFlags<TStatus>;
}
/**
* Construct the useAuthorize hook.
*/
return function useAuthorize(
options: UseAuthorizeOptions = {},
): UseAuthorizeReturn<TClient, TAction> {
// Destructure options with defaults
const isDisabled = options.isDisabled || false;
// States initialized with idle state
const [status, setStatus] = useState<"idle" | "pending" | "error" | "success">("idle");
const [error, setError] = useState<unknown>(null);
const [decision, setDecision] = useState<Decision<TClient["$$infer"]["subject"]> | null>(null);
// Get abort signal that aborts on unmount
const signalRef = useUnmountAbortSignalRef();
// Reset state on cache invalidate, automatically triggers re-fetch (unless disabled). Supports
// fine-grained invalidation by comparing cache keys.
useEffect(() => {
return Client.$hooks.onCacheInvalidate(({ matches }) => {
if (matches(policy)) {
setStatus("idle");
setError(null);
setDecision(null);
}
});
}, []);
// Run fetch
useEffect(() => {
// If disabled, reset to initial idle state
if (isDisabled) {
setStatus("idle");
setError(null);
setDecision(null);
return;
}
// If not idle, already fetching / fetched
if (status !== "idle") {
return;
}
// Fetch and go to pending
setStatus("pending");
policy
// Fetch decision
.authorize({ signal: signalRef.current })
// Handle successs
.then((value) => {
if (signalRef.current.aborted) return;
setDecision(value);
setStatus("success");
})
// Handle error
.catch((err) => {
if (signalRef.current.aborted) return;
setError(err);
setStatus("error");
});
}, [isDisabled, status, signalRef]);
/**
* Return by status to ensure type safety
*/
switch (status) {
case "idle":
return {
...getStatusWithFlags(status),
decision: null,
granted: false,
error: null,
isDisabled,
};
case "pending":
return {
...getStatusWithFlags(status),
decision: null,
granted: false,
error: null,
isDisabled,
};
case "error":
return {
...getStatusWithFlags(status),
decision: null,
granted: false,
error,
isDisabled,
};
case "success":
if (decision?.granted) {
return {
...getStatusWithFlags(status),
decision: decision as GrantedDecision<TClient["$$infer"]["subject"]>,
granted: true,
error: null,
isDisabled,
};
} else {
return {
...getStatusWithFlags(status),
// `decision` should never be null here, but we need to satisfy TypeScript
decision: decision || { granted: false },
granted: false,
error: null,
isDisabled,
};
}
}
};
}