@nexys/user-management-client
v0.3.1
Published
Pure TypeScript client for the Nexys user-management API (auth, tenants, users, roles, refresh tokens, profile). No React, no DOM.
Downloads
3,653
Readme
@nexys/user-management-client
Tiny TypeScript client for the user-management-rs API.
No React. Endpoints are plain data, calls return Result, nothing
throws. The only browser-dependent surface is the optional passkey
ceremony, which is feature-detected — everything else runs anywhere.
The wire types are kept byte-for-byte aligned with
apps/server/crates/um-server/src/wire.rs.
Pattern
type Endpoint<Input, Output, Err> = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: (input: Input) => string;
body?: (input: Input) => unknown;
query?: (input: Input) => Record<string, string | number | boolean | undefined | null>;
parseOutput: (data: unknown) => Output;
parseError: (status: number, data: unknown) => Err;
};
type Result<T, E> = { ok: true; data: T } | { ok: false; error: E };An Endpoint is pure data. createClient({ baseUrl }).call(endpoint, input)
is the only execution path; it returns
Result<Output, Err | { kind: "network" }> and never throws.
Usage
import { createClient, signIn } from "@nexys/user-management-client";
const um = createClient({
baseUrl: "https://auth.example.com",
onUnauthorized: () => location.assign("/sign-in"),
});
const res = await um.call(signIn, { email, password });
if (res.ok) {
console.log("welcome", res.data.user.email);
} else if (res.error.kind === "network") {
toast("Can't reach the server.");
} else {
// res.error.kind === "http"
toast(res.error.message);
if (res.error.code === "INVALID_EMAIL_OR_PASSWORD") highlightFields();
}For endpoints whose Input is void, the second arg is omitted:
const me = await um.call(meEndpoint);Endpoints (all named exports)
- Auth:
signUp,signIn,signOut,requestPasswordReset,resetPassword,requestMagicLink,changePassword - Identity:
me,mintToken,refreshToken,updateProfile - Refresh tokens:
listRefreshTokens,revokeRefreshToken,revokeAllRefreshTokens - Admin users:
adminListUsers,adminSetUserStatus,adminSetUserRole,adminRevokeUserTokens - Tenants:
adminListTenants,createTenant,setActiveTenant,getFullTenant,inviteMember,acceptInvitation,updateMemberRole,removeMember
Passkeys (WebAuthn)
Passkey register/sign-in is a two-round-trip ceremony (start → the
browser's navigator.credentials → finish) with base64url ⇄
ArrayBuffer wiring in the middle. The passkeys(client) facade does all
of it, so integrating is three lines. Like everything else here it
returns Result and never throws — a browser that can't do WebAuthn, or
a user who cancels the prompt, comes back as { ok: false }.
import { createClient, isConsentChallenge, passkeys } from "@nexys/user-management-client";
const um = createClient({ baseUrl: "https://auth.example.com" });
const pk = passkeys(um);
// Passwordless sign-in (discoverable credentials):
if (pk.supported()) {
const res = await pk.signIn(); // or pk.signIn({ email })
if (res.ok) {
if (isConsentChallenge(res.data)) {
// account owes a blocking consent — complete with acceptConsentChallenge
} else {
// session cookie is set; res.data.user is the signed-in user
}
} else if (res.error.kind === "passkey" && res.error.reason === "cancelled") {
// user dismissed the platform prompt — not an error to shout about
} else {
toast(res.error.message); // http / network / ceremony
}
}
// Register a passkey for the signed-in user (cookie auth):
await pk.register({ name: "MacBook Touch ID" });
// Manage:
const list = await pk.list(); // Result<PasskeyItem[]>
await pk.remove(passkeyId);For autofill UI, mark the username field
autocomplete="username webauthn" and start a conditional request:
if (await passkeyAutofillAvailable()) {
void pk.signIn({ mediation: "conditional" }); // resolves when a passkey is picked
}The raw endpoints (passkeyRegisterStart/Finish,
passkeySignInStart/Finish, listPasskeys, deleteOwnPasskey) and
the low-level ceremony helpers (createPasskeyCredential,
getPasskeyAssertion, browserSupportsPasskeys) are exported too if you
need to drive the flow yourself. The ceremony helpers are the only
browser-only part of the SDK; the rest runs anywhere.
Custom endpoints
Custom endpoints look the same; drop one in and it composes with call:
const myEndpoint: Endpoint<{ id: string }, Thing, ApiError> = {
method: "GET",
path: ({ id }) => `/api/things/${id}`,
parseOutput: (d) => d as Thing,
parseError: parseApiError,
};Tests
bun test # hermetic tests using a mock fetch (+ a stubbed WebAuthn env)
bun run typecheckReleasing
Publishing runs in CI (.github/workflows/publish-client.yml, needs the
NPM_TOKEN repo secret): bump version in package.json, then push a
matching tag —
git tag client-v0.2.0 && git push origin client-v0.2.0The workflow typechecks, tests, builds dist/, rewrites the package
entries (main/types/exports) to point at dist/ and runs
npm publish --provenance --access public.
Inside this repo the package entries stay on the TS source, so the
workspaces (apps/web's vite build, packages/web, apps/e2e) keep
importing src/ directly — only the published artifact is dist-based
(with a bun export condition so bun consumers still get the source).
