@dloizides/auth-web
v1.16.0
Published
Themeable, branded auth UI for the dloizides.com portfolio. Native LoginForm / ForgotPasswordForm / ResetPasswordForm components, headless hooks, the same-origin BffAuthClient, and a role-based post-login router. Built on @dloizides/auth-client; talks onl
Maintainers
Readme
@dloizides/auth-web
Themeable, branded auth UI for the dloizides.com portfolio — the frontend half of the unified-auth plan.
Every app gets a native, branded login experience: the login / forgot /
reset forms live inside the app's own frontend; the user is never redirected
to Keycloak's hosted login UI. All credential exchange happens server-side in
a per-app BFF (bff-katalogos, bff-erevna, ...). This package talks only
to a same-origin /bff/* — no secrets, no token handling, no Keycloak calls
in the browser.
Built on @dloizides/auth-client,
which provides the lower-level BffAuthClient.
Install
npm install @dloizides/auth-webPeer dependencies: react, react-native (optional), @tanstack/react-query,
@dloizides/auth-client (>=3.0.0).
Two ways to consume it
1. Ready-made themeable components
import {
AuthThemeProvider,
LoginForm,
createBffAuthClient,
resolvePostLoginRoute,
} from '@dloizides/auth-web';
import { katalogosAuthTheme } from './theme'; // your AuthTheme token bag
import { roleRoutes } from './roleRoutes'; // your RoleRouteTable
import { authLabels } from './authLabels'; // your localised labels
const client = createBffAuthClient(); // same-origin /bff/*
function LoginScreen() {
const router = useRouter();
return (
<AuthThemeProvider theme={katalogosAuthTheme}>
<LoginForm
client={client}
labels={authLabels.login}
onForgotPassword={() => router.push('/forgot-password')}
onSuccess={(user) => {
const route = resolvePostLoginRoute(user, roleRoutes);
router.replace(route ?? '/no-access');
}}
/>
</AuthThemeProvider>
);
}<ForgotPasswordForm> and <ResetPasswordForm> follow the same shape.
Email-OTP login — <OtpForm>
A native, branded "sign in with a code" surface — the user is never bounced to Keycloak's hosted UI. It is a two-step form: step 1 collects the email and asks the BFF to email a one-time code; step 2 collects the code, verifies it, and offers "resend code" / "use a different email".
import { OtpForm, createBffAuthClient, resolvePostLoginRoute } from '@dloizides/auth-web';
const client = createBffAuthClient();
function OtpLoginScreen() {
const router = useRouter();
return (
<OtpForm
client={client}
labels={authLabels.otp}
onSuccess={(user) => {
const route = resolvePostLoginRoute(user, roleRoutes);
router.replace(route ?? '/no-access');
}}
/>
);
}<OtpForm> POSTs to the same-origin /bff/otp/request and /bff/otp/verify
endpoints (added in Bff.AspNetCore). The BFF runs the OTP direct-grant against
Keycloak server-side; the browser receives only the httpOnly session cookie.
Event-PIN login — <PinForm>
A native, branded "sign in with your event PIN" surface for operational staff
(door / DJ / media on Kefi). It is a single-step form: a PIN field + a "sign
in" button. The eventExternalId is a prop — the event context comes from the
route/page, never typed by the user. The (event, pin) pair alone identifies
the staff member; no username/password ever leaves the browser.
import { PinForm, createBffAuthClient, resolvePostLoginRoute } from '@dloizides/auth-web';
const client = createBffAuthClient();
function PinLoginScreen({ eventExternalId }: { eventExternalId: string }) {
const router = useRouter();
return (
<PinForm
client={client}
eventExternalId={eventExternalId}
labels={authLabels.pin}
onSuccess={(user) => {
const route = resolvePostLoginRoute(user, roleRoutes);
router.replace(route ?? '/no-access');
}}
/>
);
}<PinForm> POSTs to the same-origin /bff/pin/login endpoint (added in
Bff.AspNetCore). The BFF runs the event-scoped PIN direct-grant against
Keycloak server-side; the browser receives only the httpOnly session cookie.
2. Headless hooks (custom layout)
import { useBffAuth, createBffAuthClient } from '@dloizides/auth-web';
const client = createBffAuthClient();
function CustomLogin() {
const { login, isSubmitting, error } = useBffAuth({ client, probeOnMount: false });
// ...render your own form, call login({ username, password })
}For a custom OTP layout, useOtpLogin exposes the two-step machine:
import { useOtpLogin, OtpLoginStep, createBffAuthClient } from '@dloizides/auth-web';
const client = createBffAuthClient();
function CustomOtpLogin() {
const otp = useOtpLogin({ client });
// step 1: otp.requestCode(email) → otp.step becomes OtpLoginStep.EnterCode
// step 2: otp.verifyCode(code) → resolves to the signed-in BffUser
// otp.resend() / otp.reset() for the step-2 affordances
}For a custom PIN layout, usePinLogin exposes the single-step flow:
import { usePinLogin, createBffAuthClient } from '@dloizides/auth-web';
const client = createBffAuthClient();
function CustomPinLogin({ eventExternalId }: { eventExternalId: string }) {
const pin = usePinLogin({ client, eventExternalId });
// pin.submit(pinValue) → resolves to the signed-in BffUser
// pin.reset() → clears the error
}Theming
The package owns no brand. Each app maps its own theme system onto the flat
AuthTheme token bag (colors, radii, spacing, typography) and supplies
it via <AuthThemeProvider> or a theme prop on an individual component.
Precedence: prop → context → defaultAuthTheme.
import { defaultAuthTheme, type AuthTheme } from '@dloizides/auth-web';
export const katalogosAuthTheme: AuthTheme = {
...defaultAuthTheme,
colors: { ...defaultAuthTheme.colors, primary: '#c2410c' },
};Because all three forms share one useAuthStyles token-to-style mapping,
re-theming <LoginForm> automatically re-themes the others.
Public demo credentials — <DemoCredentialsHint>
For a product with an OPEN demo account whose credentials are already published (typically on the marketing site). It shows the username + password on the login card and offers a one-tap fill, so a non-technical evaluator never copy-pastes a password between two tabs.
It fills fields, nothing more. The login then travels the ordinary path — same BFF, same Keycloak, same session cookie, same roles. There is no auth bypass and no credential inside this package: the host supplies the pair, and the demo account's blast radius is whatever the IdP grants it.
The demo pair comes from the BFF (GET /bff/config → config.demo), not a
client const — the BFF is the only thing that knows whether a deployment has a
demo configured, and a bundled const would ship credentials into every customer's
self-hosted build. useBffLoginConfig already fetches that config, so it also
carries demo. useDemoCredentials owns the hint↔form wiring:
import {
AuthScreen, DemoCredentialsHint, LoginForm,
useBffLoginConfig, useDemoCredentials,
} from '@dloizides/auth-web';
function LoginScreen() {
const { config } = useBffLoginConfig(bffAuthClient);
// Credentials arrive ASYNC — prefillOnMount honours a late arrival (see below).
const demo = useDemoCredentials({ credentials: config.demo ?? undefined, prefillOnMount: true });
return (
<AuthScreen theme={authTheme}>
<DemoCredentialsHint
{...demo.hintProps}
labels={authLabels.demo}
testIdPrefix="zygos"
theme={authTheme}
/>
<LoginForm
{...demo.loginFormProps}
chromeless
client={bffAuthClient}
labels={authLabels.login}
theme={authTheme}
onSuccess={handleSignedIn}
/>
</AuthScreen>
);
}hintProps carries { credentials, onUse }; loginFormProps carries
{ initialUsername, initialPassword, prefillNonce, prefillIntent }. A module-const
demo works too — just pass it as credentials instead of config.demo.
Requires
@dloizides/auth-client >= 4.2.0, which surfacesdemoonBffLoginConfig(mapped from the wire'spublishedUsername/publishedPassword).config.demoisDemoCredentials | null.
Async credentials: prefillOnMount honours a late arrival
Because the pair arrives a render or two after mount, prefillOnMount cannot lean
on useState's initial value (React reads it on the first render only). The hook
seeds the form when config.demo resolves to a usable pair — but as an
opportunistic seed: <LoginForm> applies it only while both fields are still
empty, so a late /bff/config response never overwrites a username a real account
holder was typing meanwhile. Tapping Use these credentials is an explicit
seed and always applies. Seeding is gated by value, so a react-query refetch
returning an equal-valued object does not re-seed. useDemoCredentials chooses
the intent for you via the prefillIntent prop; you only touch PrefillIntent
if you drive <LoginForm> without the hook.
prefillOnMount (default false) chooses the trade:
| | Fields on arrival | To sign in as the demo user | Cost |
|---|---|---|---|
| false | empty | tap Use these credentials, then Sign in | — |
| true | filled | tap Sign in | a real account holder must clear two fields first |
Use true only on a genuinely public demo surface. The panel renders either way,
so a visitor can always see why the fields are populated.
It fails closed
No credentials — or a half-configured pair (blank username, blank password, or a
field that is undefined at runtime because an env var was never set) — renders
nothing at all: zero DOM. A misconfigured demo can never publish an empty
credentials panel. resolveDemoCredentials is exported if you need the same
decision elsewhere.
The prefill is a seed, not a leash
initialUsername / initialPassword choose the fields' starting value and
then get out of the way: every field stays editable and clearable, so a real
account holder can always wipe the demo values and sign in with their own. This
is why prefillNonce exists — React ignores a useState initial argument on
re-render, so changing initial* alone does nothing once mounted, and a "fill
the form" button wired without the nonce would leave the form stubbornly empty.
useDemoCredentials manages it for you; drive it yourself only if you are not
using the hook.
Internationalisation
@dloizides/auth-web ships no i18n framework. Every user-facing string is
supplied through a typed labels prop. Apps pass strings already localised
with their own FM() / t(). Each label bag is partial — unspecified keys
fall back to the English DEFAULT_* constants.
Role-based post-login routing
import { resolvePostLoginRoute, type RoleRouteTable } from '@dloizides/auth-web';
const roleRoutes: RoleRouteTable = {
routes: [
{ role: 'superUser', route: '/admin/super' },
{ role: 'admin', route: '/admin' },
{ role: 'user', route: '/dashboard' },
],
fallback: '/no-access',
};
// The first table entry whose role the user holds wins — list most privileged first.
const route = resolvePostLoginRoute(user, roleRoutes);API surface
| Export | Kind |
|--------|------|
| LoginForm, ForgotPasswordForm, ResetPasswordForm, OtpForm, PinForm | Components |
| AuthScreen, DemoCredentialsHint, PreferredMethodHint | Composition + login-surface panels |
| useDemoCredentials, resolveDemoCredentials, DemoCredentials, PrefillIntent | Public demo credentials |
| AuthThemeProvider, useAuthTheme, defaultAuthTheme, AuthTheme | Theming |
| DEFAULT_LOGIN_LABELS, DEFAULT_FORGOT_PASSWORD_LABELS, DEFAULT_RESET_PASSWORD_LABELS, DEFAULT_OTP_LABELS, DEFAULT_PIN_LABELS | Label bags |
| useBffAuth, useBffForgotPassword, useBffResetPassword, useResetPasswordForm, useOtpLogin, usePinLogin | Headless hooks |
| OtpLoginStep | OTP step enum |
| createBffAuthClient, BffAuthClient (re-export) | Client |
| resolvePostLoginRoute, collectUserRoles, RoleRouteTable | Router |
| validatePasswordPolicy, isPasswordValid, PasswordPolicyError | Password policy |
| AuthTestIds, withTestIdPrefix | Test IDs |
License
MIT
