@dloizides/auth-web
v1.5.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.
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 |
| 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
